Compare commits
@@ -3,54 +3,62 @@
|
||||
A comprehensive workflow, asset management, and generation UI plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI).
|
||||
|
||||
**📁 Management** — organize all your assets in one studio
|
||||
|
||||
- Workflow, model, image, and prompt management
|
||||
- AI-powered prompt writing assistance, translation, and tag generation
|
||||
- File drop & Gallery-linked metadata viewing — inspect and reuse generation settings instantly
|
||||
|
||||
**⚡ GenerateUI** — a productivity-focused generation tab connected to every other tab
|
||||
|
||||
- Batch generation across models, samplers, prompts, and workflows
|
||||
- Image Feeder for continuous folder-based generation
|
||||
|
||||
**📚 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 |
|
||||
| :----------------------------------------------------------: | :---------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| GenUI LoRA Stack | GenUI Batch |
|
||||
|:---:|:---:|
|
||||
| GenUI LoRA Stack | GenUI Batch |
|
||||
| :---------------------------------------------: | :-------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Models Multi-select Menu | Top Bar |
|
||||
|:---:|:---:|
|
||||
| Models Multi-select Menu | Top Bar |
|
||||
| :-----------------------------------------------------------: | :---------------------------: |
|
||||
|  |  |
|
||||
|
||||
| WS Library | Library Information |
|
||||
|:---:|:---:|
|
||||
| WS Library | Library Information |
|
||||
| :----------------------------------: | :---------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Customize | |
|
||||
|:---:|:---:|
|
||||
|  | |
|
||||
| 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)
|
||||
@@ -60,34 +68,46 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **AI summary** — generate workflow descriptions using Ollama
|
||||
- **Import / Export** — import workflows from files or clipboard; **Send to Canvas** button sends the selected workflow directly to the ComfyUI canvas (UI and API formats both supported)
|
||||
- **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 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
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder, **Hypernetwork** (with Strength field) selectors with filter; Raw JSON on the right
|
||||
- **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 / 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
|
||||
@@ -100,6 +120,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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
|
||||
@@ -107,6 +128,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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
|
||||
@@ -120,6 +142,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
@@ -130,6 +153,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
@@ -141,13 +165,16 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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.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
|
||||
@@ -163,19 +190,30 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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; **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
|
||||
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
|
||||
- **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
|
||||
@@ -196,8 +234,11 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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
|
||||
@@ -208,16 +249,22 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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.53)
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw types) and export the composite as PNG
|
||||
### 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
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S); tool options bar updates per active tool
|
||||
- **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
|
||||
- **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)
|
||||
- **Layer panel** — layer list with visibility (👁/🚫) and lock (🔒/🔓) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw); Layer 1 is automatically locked on first image load
|
||||
- **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)
|
||||
@@ -225,17 +272,26 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
|
||||
- **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 or LM Studio; 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 uses `/v1/chat/completions`
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
|
||||
- **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/chat` for conversations, `/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
|
||||
@@ -244,7 +300,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; 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`
|
||||
- **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
|
||||
@@ -258,8 +314,9 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **⚙ 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 (15 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
|
||||
- **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
|
||||
|
||||
@@ -334,14 +391,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 |
|
||||
@@ -350,20 +409,198 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.78
|
||||
|
||||
- **Eagle integration — SVG support** — auto-save now handles SVG output alongside raster images: the standard `SaveSVGNode` (via `images`-key detection with correct `image/svg+xml` MIME re-tagging) and `comfyui-tosvg`'s `Save SVG String` node (via its `saved_svg`/`path` UI keys, including a fix for ComfyUI's execution engine splitting single-string UI values into per-character arrays); SVGs are sent to Eagle through `addFromPath` (resolved to a real on-disk path, restricted to ComfyUI's managed directories) instead of `addFromURL`, since `/view` forces `application/octet-stream` for SVG as an XSS guard; temp-only preview images (e.g. `SVG String Preview`) are excluded from both auto-save and the GenerateUI result display, which now shows the actual saved SVG file instead of a stale pre-vectorized preview
|
||||
- **GenerateUI / Metadata tabs — subgraph workflow support (Mage-Flow, and any similar template)** — workflows that merely contain ComfyUI subgraphs (`definitions.subgraphs`) are no longer blocked as unsupported "App format"; only true simplified-UI App-mode workflows (Linear Mode / `.app.json`) still are. `convertUiToApi()` now correctly injects the outer subgraph node's actual widget values (prompt text, seed, model names, etc. — what the user actually sees in the collapsed subgraph form) into the internal template nodes, which previously kept stale placeholder values from when the subgraph was authored; added support for `TextEncodeMageFlowEdit` (prompt analysis) and ComfyUI's new DynamicCombo widget type (e.g. `SaveImageAdvanced`'s format selector)
|
||||
- **Workflow tab — structured AI summary** — the "Summarize" button now extracts model names, LoRAs, prompts, and sampler settings via `analyzeWorkflow()` instead of dumping the raw workflow JSON's first 4000 characters, which used to get crowded out by long `MarkdownNote` text or UI position data on complex/subgraph workflows
|
||||
- **Workflow tab — accurate node counts and format badge** — the side panel's "P:/I:/→" counts and the API/App format badge now recognize `TextEncodeMageFlowEdit`/`SaveImageAdvanced` and other newer node types, and use the same "App means Linear Mode, not just subgraphs" rule as GenerateUI
|
||||
- **Workflow/Gallery tabs — clearer "Send to Canvas" toast** — the toast previously always said "drag the title to canvas" even when the workflow was sent directly via `window.opener` (no drag needed); it now only shows the drag instruction for the actual drag-required fallback path
|
||||
|
||||
### v0.3.77
|
||||
|
||||
- **Gallery Tab — SVG file support** — `.svg` added to `IMAGE_EXTENSIONS` (`gallery_service.py`), covering folder scanning, listing, move/delete, and serving; `serve_thumbnail()` returns the original file for SVG (same as the existing GIF case) since Pillow can't open vector images and the browser scales them natively; no frontend changes needed since `gallery-tab.js` renders every image through plain `<img>` tags
|
||||
- **AI TOOL Chat — SVG generation without a ComfyUI workflow** — assistant replies are now scanned for SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`); when found, it's rendered inline via `<img src="data:image/svg+xml;base64,...">` (script execution is disabled by the browser for SVG loaded through `<img>`) with a "Save to Gallery" button; `/wfm/gallery/image/save` (`gallery_routes.py`) was generalized to detect the target format from the data URL's MIME type instead of assuming PNG, so both Image Edit Tab's PNG saves and Chat's SVG saves go through the same endpoint
|
||||
|
||||
### 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
|
||||
@@ -371,33 +608,40 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **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()`
|
||||
@@ -410,10 +654,12 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **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
|
||||
@@ -425,6 +671,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **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
|
||||
@@ -432,17 +679,20 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- 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)
|
||||
@@ -453,11 +703,13 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **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
|
||||
@@ -467,11 +719,13 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **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)
|
||||
@@ -479,20 +733,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
|
||||
@@ -511,22 +769,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
|
||||
@@ -535,6 +797,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
|
||||
@@ -543,29 +806,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
|
||||
@@ -575,15 +844,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
|
||||
@@ -593,44 +865,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
|
||||
@@ -638,10 +918,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
|
||||
@@ -651,10 +933,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
|
||||
@@ -662,18 +946,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
|
||||
@@ -681,6 +968,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
|
||||
@@ -693,6 +981,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)
|
||||
@@ -704,47 +993,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
|
||||
|
||||
---
|
||||
@@ -826,3 +1124,5 @@ MIT License
|
||||
- [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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
+103
-2
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
@@ -17,6 +18,84 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/eagle/test", handle_test)
|
||||
|
||||
|
||||
def _resolve_local_path(filename, subfolder, type_name):
|
||||
"""filename/subfolder/type を ComfyUI 出力ディレクトリ上の実パスに解決する。
|
||||
ComfyUI と Eagle が同一マシン上で動作している場合のみ有効なパスが返る
|
||||
(別マシンの場合は None を返し、呼び出し側は addFromURL にフォールバックする)。"""
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
base_dir = folder_paths.get_directory_by_type(type_name or "output")
|
||||
if not base_dir:
|
||||
return None
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
|
||||
filename = os.path.basename(filename or "")
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
full_dir = os.path.abspath(os.path.join(base_dir, subfolder or ""))
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(full_dir), os.path.normcase(base_dir))) != os.path.normcase(base_dir):
|
||||
# ディレクトリトラバーサル対策: base_dir 配下以外は拒否
|
||||
return None
|
||||
|
||||
full_path = os.path.join(full_dir, filename)
|
||||
if not os.path.isfile(full_path):
|
||||
return None
|
||||
return full_path
|
||||
|
||||
|
||||
def _resolve_absolute_svg_path(abs_path):
|
||||
"""comfyui-tosvg の Save SVG String ノードなどが返す絶対パスを検証する。
|
||||
ComfyUI管理下(output/input/temp)ディレクトリ配下の実在する .svg ファイルのみ許可する
|
||||
(任意の絶対パスをそのまま信頼すると、外部からこのAPIを叩かれた際に
|
||||
ローカルの任意ファイルを送信させられてしまうため)。"""
|
||||
if not abs_path or not abs_path.lower().endswith(".svg"):
|
||||
return None
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
abs_path = os.path.abspath(abs_path)
|
||||
if not os.path.isfile(abs_path):
|
||||
return None
|
||||
|
||||
for type_name in ("output", "temp", "input"):
|
||||
base_dir = folder_paths.get_directory_by_type(type_name)
|
||||
if not base_dir:
|
||||
continue
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
try:
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(abs_path), os.path.normcase(base_dir))) == os.path.normcase(base_dir):
|
||||
return abs_path
|
||||
except ValueError:
|
||||
# Windowsで異なるドライブレターの場合 commonpath が例外を投げる
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _eagle_add_from_path(eagle_url, file_path, name, tags):
|
||||
"""Proxy request to Eagle's addFromPath API (runs in thread).
|
||||
SVG は ComfyUI の /view がセキュリティ上 application/octet-stream +
|
||||
attachment で返すため、addFromURL 経由だと正しい種別で保存されないことがある。
|
||||
ComfyUI と Eagle が同一マシン上にある場合はローカルパスを直接渡す方が確実。"""
|
||||
eagle_url = eagle_url.rstrip("/")
|
||||
payload = json.dumps({"path": file_path, "name": name, "tags": tags}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{eagle_url}/api/item/addFromPath",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _eagle_add(eagle_url, image_url, name, tags):
|
||||
"""Proxy request to Eagle API (runs in thread)."""
|
||||
eagle_url = eagle_url.rstrip("/")
|
||||
@@ -71,11 +150,33 @@ async def handle_add(request: web.Request) -> web.Response:
|
||||
image_url = body.get("url", "")
|
||||
name = body.get("name", "image.png")
|
||||
tags = body.get("tags", [])
|
||||
filename = body.get("filename", "")
|
||||
subfolder = body.get("subfolder", "")
|
||||
file_type = body.get("type", "output")
|
||||
local_path_hint = body.get("localPath", "")
|
||||
|
||||
if not image_url:
|
||||
if not image_url and not local_path_hint:
|
||||
return web.json_response({"status": "error", "message": "No URL provided"}, status=400)
|
||||
|
||||
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
|
||||
# SVG は /view が application/octet-stream + attachment で返すため、
|
||||
# ローカルファイルが解決できる場合は addFromPath で直接渡す
|
||||
local_path = None
|
||||
if local_path_hint:
|
||||
# comfyui-tosvg の Save SVG String ノードなど、絶対パスが既知の場合
|
||||
local_path = _resolve_absolute_svg_path(local_path_hint)
|
||||
elif (filename or name or "").lower().endswith(".svg"):
|
||||
local_path = _resolve_local_path(filename or name, subfolder, file_type)
|
||||
|
||||
if not local_path and not image_url:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": f"Could not resolve local SVG path: {local_path_hint}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if local_path:
|
||||
result = await asyncio.to_thread(_eagle_add_from_path, eagle_url, local_path, name, tags)
|
||||
else:
|
||||
result = await asyncio.to_thread(_eagle_add, eagle_url, image_url, name, tags)
|
||||
return web.json_response(result)
|
||||
except urllib.error.URLError as e:
|
||||
logger.error("Eagle URL error: %s", e)
|
||||
|
||||
@@ -260,8 +260,18 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
"""Image Edit Tab からの PNG をギャラリーのルートフォルダに保存する。"""
|
||||
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
|
||||
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "").strip()
|
||||
@@ -273,15 +283,21 @@ async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
if _service._allowed_root is None:
|
||||
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
|
||||
|
||||
# data URL から MIME タイプを判定し、拡張子を決定する
|
||||
raw = image_data
|
||||
mime = "image/png"
|
||||
if raw.startswith("data:"):
|
||||
header, _, raw = raw.partition(",")
|
||||
m = re.match(r"data:([^;]+)", header)
|
||||
if m:
|
||||
mime = m.group(1)
|
||||
ext = _SAVE_EXT_BY_MIME.get(mime, ".png")
|
||||
|
||||
# ファイル名サニタイズ(OSで使えない文字を除去)
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(".png"):
|
||||
safe += ".png"
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
@@ -684,6 +684,11 @@ class GalleryService:
|
||||
if p.suffix.lower() == ".gif":
|
||||
return p
|
||||
|
||||
# SVG はベクター画像でPillowが開けないため、元ファイルをそのまま返す
|
||||
# (<img>タグはブラウザ側で任意サイズにスケーリングして表示する)
|
||||
if p.suffix.lower() == ".svg":
|
||||
return p
|
||||
|
||||
try:
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
except OSError:
|
||||
|
||||
@@ -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"]
|
||||
@@ -71,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")
|
||||
@@ -113,7 +117,15 @@ class ModelsService:
|
||||
return entry
|
||||
|
||||
def _scan_model_names(self, model_type: str) -> set:
|
||||
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.disabled除く)"""
|
||||
"""モデルタイプの全ファイル名をスキャンして返す(相対パス、/区切り、.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:
|
||||
@@ -129,6 +141,8 @@ class ModelsService:
|
||||
names.add(rel)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._scan_cache[model_type] = (now, names)
|
||||
return names
|
||||
|
||||
def list_model_files(self, model_type: str) -> list:
|
||||
@@ -170,17 +184,36 @@ class ModelsService:
|
||||
|
||||
if 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():
|
||||
filtered = [m for m in members if m in valid_names]
|
||||
# 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):
|
||||
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)
|
||||
logger.info("Cleaned up %d stale group entries for model_type=%s", removed, model_type)
|
||||
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
|
||||
@@ -189,12 +222,16 @@ class ModelsService:
|
||||
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.
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
# プロンプトを直接持つノードタイプ(CLIPTextEncode以外の複合ノード含む)
|
||||
_PROMPT_NODE_TYPES = {
|
||||
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSDXLRefiner",
|
||||
"CLIPTextEncodeEditPlus", "TextEncodeQwenImageEditPlus", "TextEncodeMageFlowEdit",
|
||||
"SDXLPromptStyler", "SDXLPromptStylerAdvanced", "ImageMetadataPromptLoader",
|
||||
"WFS_PromptText", "ImpactWildcardEncode", "ImpactWildcardProcessor",
|
||||
}
|
||||
|
||||
# 画像を最終出力として保存するノードタイプ
|
||||
_OUTPUT_IMAGE_NODE_TYPES = {"SaveImage", "SaveImageAdvanced"}
|
||||
|
||||
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
|
||||
_CLIP_TYPE_TO_MODEL = {
|
||||
"flux": "Flux",
|
||||
@@ -181,25 +192,27 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
if "hidream" in title:
|
||||
model_types.add("HiDream")
|
||||
|
||||
if ntype == "CLIPTextEncode" or "prompt" in title:
|
||||
if ntype in _PROMPT_NODE_TYPES or "prompt" in title:
|
||||
inputs["prompts"] += 1
|
||||
if ntype in ("LoadImage", "LoadImageMask"):
|
||||
inputs["images"] += 1
|
||||
if ntype == "LoadVideo" or "load video" in title:
|
||||
inputs["videos"] += 1
|
||||
if ntype == "SaveImage":
|
||||
if ntype in _OUTPUT_IMAGE_NODE_TYPES:
|
||||
outputs["images"] += 1
|
||||
if ntype in ("SaveVideo", "VH_VideoCombine"):
|
||||
outputs["videos"] += 1
|
||||
|
||||
# Detect workflow format
|
||||
if raw_nodes is not None:
|
||||
# App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
# App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
# A workflow that merely *contains* subgraphs (definitions.subgraphs) is still a
|
||||
# normal node graph (comfyui-workflow.js's convertUiToApi flattens subgraphs before
|
||||
# conversion), so it's treated as "ui" here too — must match detectFormat() in
|
||||
# static/js/comfyui-workflow.js or the App/UI badge shown to the user disagrees
|
||||
# with what GenerateUI actually does with the file.
|
||||
extra = workflow_data.get("extra", {})
|
||||
if (
|
||||
"definitions" in workflow_data
|
||||
or extra.get("linearMode") is True
|
||||
):
|
||||
if extra.get("linearMode") is True:
|
||||
wf_format = "app"
|
||||
elif filename and filename.lower().endswith(".app.json"):
|
||||
wf_format = "app"
|
||||
|
||||
@@ -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, tagger_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)
|
||||
@@ -73,5 +73,6 @@ class WorkflowStudio:
|
||||
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
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.3.54"
|
||||
version = "0.3.78"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -232,12 +232,75 @@
|
||||
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: 190px;
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -318,7 +381,7 @@
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--wfm-text);
|
||||
opacity: 0.5;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
============================================ */
|
||||
@@ -5163,6 +5265,9 @@ body {
|
||||
.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);
|
||||
@@ -5174,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);
|
||||
@@ -5391,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;
|
||||
@@ -5467,6 +5608,79 @@ body {
|
||||
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-svg-wrapper {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-svg-img {
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
|
||||
+314
-31
@@ -1,11 +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 } from "./util.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";
|
||||
|
||||
@@ -196,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");
|
||||
@@ -285,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,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";
|
||||
@@ -348,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");
|
||||
});
|
||||
|
||||
@@ -368,30 +419,190 @@ function initSettingsTab() {
|
||||
// Chat tab
|
||||
// ============================================
|
||||
|
||||
async function callChat(url, backend, model, messages) {
|
||||
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({ model, messages, stream: false }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
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({ model, messages, stream: false }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content) {
|
||||
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")}`;
|
||||
}
|
||||
|
||||
// アシスタント応答からSVGコードを抽出する(```svg フェンス or 生の <svg>...</svg>)
|
||||
function _extractSvgCode(text) {
|
||||
if (!text) return null;
|
||||
const fenced = text.match(/```(?:svg|xml)?\s*([\s\S]*?<svg[\s\S]*?<\/svg>)[\s\S]*?```/i);
|
||||
if (fenced) return fenced[1].trim();
|
||||
const bare = text.match(/<svg[\s\S]*?<\/svg>/i);
|
||||
if (bare) return bare[0].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// SVGコードをプレビュー表示し、Galleryへ保存するボタンを追加する。
|
||||
// <img src="data:..."> でのレンダリングはブラウザがスクリプト実行を無効化するため安全。
|
||||
function _appendSvgPreview(container, svgCode) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "wfm-ai-chat-svg-wrapper";
|
||||
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgCode)))}`;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = dataUrl;
|
||||
img.className = "wfm-ai-chat-svg-img";
|
||||
img.alt = "Generated SVG";
|
||||
wrapper.appendChild(img);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "wfm-btn wfm-btn-sm";
|
||||
saveBtn.textContent = t("aiChatSaveSvgToGallery");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: `chat_svg_${Date.now()}.svg`, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
showToast(t("aiChatSvgSaved"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("aiChatSvgSaveFailed") + e.message, "error");
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
wrapper.appendChild(saveBtn);
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = `wfm-ai-chat-msg wfm-ai-chat-msg-${role}`;
|
||||
div.textContent = content;
|
||||
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);
|
||||
}
|
||||
if (role === "assistant" && content) {
|
||||
const svgCode = _extractSvgCode(content);
|
||||
if (svgCode) _appendSvgPreview(div, svgCode);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
@@ -403,9 +614,22 @@ function initChatTab() {
|
||||
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() {
|
||||
@@ -414,7 +638,7 @@ function initChatTab() {
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -427,17 +651,39 @@ function initChatTab() {
|
||||
|
||||
inputEl.value = "";
|
||||
inputEl.style.height = "";
|
||||
chatHistory.push({ role: "user", content: text });
|
||||
appendChatBubble(messagesEl, "user", text);
|
||||
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 reply = await callChat(url, backend, model, chatHistory);
|
||||
chatHistory.push({ role: "assistant", content: reply });
|
||||
appendChatBubble(messagesEl, "assistant", reply);
|
||||
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) {
|
||||
@@ -469,14 +715,49 @@ function initChatTab() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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");
|
||||
@@ -489,8 +770,6 @@ function initVlmTab() {
|
||||
|
||||
if (!dropEl) return;
|
||||
|
||||
let vlmImage = null; // { base64, mimeType }
|
||||
|
||||
function updateTaskUI() {
|
||||
const isWildcard = taskSel?.value === "wildcard";
|
||||
dropEl.style.display = isWildcard ? "none" : "";
|
||||
@@ -501,10 +780,9 @@ function initVlmTab() {
|
||||
|
||||
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());
|
||||
@@ -516,12 +794,17 @@ 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 () => {
|
||||
const task = taskSel?.value || "describe";
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) { showToast(t("aiToastInvalidUrl"), "error"); return; }
|
||||
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
|
||||
@@ -557,7 +840,7 @@ function initVlmTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
if (!_toolsImage) { showToast(t("aiToastNoImage"), "error"); return; }
|
||||
|
||||
runBtn.disabled = true;
|
||||
statusEl.textContent = t("aiStatusRunning");
|
||||
@@ -565,7 +848,7 @@ function initVlmTab() {
|
||||
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";
|
||||
|
||||
+69
-1
@@ -310,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");
|
||||
@@ -355,6 +365,8 @@ function applyI18nToHtml() {
|
||||
"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-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",
|
||||
@@ -384,6 +396,7 @@ 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",
|
||||
@@ -398,6 +411,8 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
|
||||
"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",
|
||||
@@ -418,6 +433,59 @@ function applyI18nToHtml() {
|
||||
"wfm-help-ai-title": "helpAiTitle",
|
||||
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"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",
|
||||
@@ -425,7 +493,7 @@ function applyI18nToHtml() {
|
||||
"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-6": "helpTrouble6",
|
||||
"wfm-help-trouble-7": "helpTrouble7",
|
||||
"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",
|
||||
|
||||
+34
-10
@@ -132,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() {
|
||||
@@ -238,7 +240,13 @@ export const comfyUI = {
|
||||
});
|
||||
const res = await fetch(`${this.baseUrl}/view?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch image: HTTP ${res.status}`);
|
||||
return await res.blob();
|
||||
const blob = await res.blob();
|
||||
// /view はSVGをセキュリティ上application/octet-streamで返すため、
|
||||
// <img>で表示できるよう拡張子から正しいMIMEタイプへ付け替える
|
||||
if (imageData.filename?.toLowerCase().endsWith(".svg") && blob.type !== "image/svg+xml") {
|
||||
return blob.slice(0, blob.size, "image/svg+xml");
|
||||
}
|
||||
return blob;
|
||||
},
|
||||
|
||||
async uploadImage(file, filename, overwrite = true) {
|
||||
@@ -298,6 +306,9 @@ export const comfyUI = {
|
||||
|
||||
// Extract images from SaveImage outputs only
|
||||
const images = [];
|
||||
// comfyui-tosvg の "Save SVG String" ノードは images ではなく
|
||||
// saved_svg(ファイル名) / path(絶対パス) という独自キーで返す
|
||||
const svgOutputs = [];
|
||||
const outputs = history.outputs || {};
|
||||
for (const nodeOutput of Object.values(outputs)) {
|
||||
if (nodeOutput.images) {
|
||||
@@ -305,10 +316,23 @@ export const comfyUI = {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
if (nodeOutput.saved_svg) {
|
||||
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
|
||||
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
|
||||
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
|
||||
const joinIfCharArray = (v) =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
|
||||
? v.join("")
|
||||
: v;
|
||||
svgOutputs.push({
|
||||
filename: joinIfCharArray(nodeOutput.saved_svg),
|
||||
path: joinIfCharArray(nodeOutput.path) || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(images, seed);
|
||||
return { images, seed };
|
||||
return { images, seed, svgOutputs };
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
throw err;
|
||||
|
||||
+617
-18
@@ -4,6 +4,8 @@
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
|
||||
// ── Latent Image preset state ─────────────────────────────
|
||||
const _LATENT_PRESET_KEY = "wfm_latent_presets";
|
||||
@@ -85,7 +87,7 @@ function _applyLoraToNode(nodeId, loraPath, strModel, strClip, isLoraManager) {
|
||||
};
|
||||
comfyUI.currentWorkflow[nodeId].inputs.text = `<lora:${stem}:${strModel}:${strClip}>`;
|
||||
} else {
|
||||
comfyUI.currentWorkflow[nodeId].inputs.lora_name = loraPath;
|
||||
comfyUI.currentWorkflow[nodeId].inputs.lora_name = comfyEditor.resolveLoraName(loraPath);
|
||||
comfyUI.currentWorkflow[nodeId].inputs.strength_model = strModel;
|
||||
comfyUI.currentWorkflow[nodeId].inputs.strength_clip = strClip;
|
||||
}
|
||||
@@ -153,6 +155,142 @@ function _syncRawJson() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inpaint: 画像+マスクのRGBA合成 ──────────────────────
|
||||
// mask は白=インペイント対象領域/黒=維持領域のグレースケール画像を想定(Image Editの
|
||||
// マスクレイヤーも、手動でドロップする白黒マスクファイルも同じ規約)。
|
||||
// ComfyUIネイティブのLoadImageはアルファチャンネルから mask = 1 - alpha を抽出するため、
|
||||
// 合成後の alpha は 255 - maskGray とする。
|
||||
async function _loadImageElement(src) {
|
||||
// `instanceof Blob` は別レルム(他ウィンドウ/iframe)で生成されたBlob/Fileでは
|
||||
// falseになる(コンストラクタの参照が異なるため)。Comic CreaterからiframeへBlobを
|
||||
// 直接渡すInpaint連携で発生するため、実体で判定する(文字列URLかどうかだけを見る)。
|
||||
const isBlobLike = typeof src !== "string";
|
||||
const url = isBlobLike ? URL.createObjectURL(src) : src;
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error("Failed to load image"));
|
||||
});
|
||||
return img;
|
||||
} finally {
|
||||
if (isBlobLike) URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function _compositeImageWithMask(imageInput, maskInput) {
|
||||
const img = await _loadImageElement(imageInput);
|
||||
const mask = await _loadImageElement(maskInput);
|
||||
|
||||
const w = img.naturalWidth, h = img.naturalHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const imgData = ctx.getImageData(0, 0, w, h);
|
||||
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = w; maskCanvas.height = h;
|
||||
const mctx = maskCanvas.getContext("2d");
|
||||
mctx.drawImage(mask, 0, 0, w, h);
|
||||
const maskData = mctx.getImageData(0, 0, w, h).data;
|
||||
|
||||
const data = imgData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i + 3] = 255 - maskData[i];
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
}
|
||||
|
||||
// ── Inpaint: Mask Editor One (comfyui-mask-editor-one) ノード向けヘルパー ──────
|
||||
// この custom node は LoadImage を介さず、画像は /mask_editor/store_image でサーバー側
|
||||
// キャッシュ(bg_image_b64, node_idキー)へ、マスクは layer_data(JSON文字列)ウィジェットの
|
||||
// 各レイヤーの「アルファチャンネル」から合成される(LoadImageの 1-alpha とは逆で、
|
||||
// alpha値がそのままmask値になる規約)。
|
||||
async function _imageInputToDataURL(imageInput) {
|
||||
const img = await _loadImageElement(imageInput);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
async function _maskInputToAlphaDataURL(maskInput) {
|
||||
const img = await _loadImageElement(maskInput);
|
||||
const w = img.naturalWidth, h = img.naturalHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const imgData = ctx.getImageData(0, 0, w, h);
|
||||
|
||||
const data = imgData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const gray = data[i];
|
||||
data[i] = data[i + 1] = data[i + 2] = 255;
|
||||
data[i + 3] = gray;
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// ── プロンプト強調weight編集 (Ctrl+↑/↓) ──────────────────────
|
||||
// A1111 / ComfyUIネイティブのCLIP Text Encodeと同様の操作感:
|
||||
// 選択(無ければカーソル位置の括弧ブロックまたは単語)を (text:weight) 形式に変換し、
|
||||
// weightを0.05刻みで増減する。weightが1.0に戻ったら括弧を除去する。
|
||||
const _PROMPT_WEIGHT_DELIMITERS = new Set([",", "\n", "\r", "\t", " ", "(", ")", ":"]);
|
||||
|
||||
function _selectSurroundingParenBlock(text, pos) {
|
||||
const openIdx = text.lastIndexOf("(", pos - 1);
|
||||
if (openIdx === -1) return null;
|
||||
const closeBefore = text.lastIndexOf(")", pos - 1);
|
||||
if (closeBefore > openIdx) return null; // カーソルは既に閉じたブロックの外
|
||||
const closeIdx = text.indexOf(")", pos);
|
||||
if (closeIdx === -1) return null;
|
||||
return { start: openIdx, end: closeIdx + 1 };
|
||||
}
|
||||
|
||||
function _selectSurroundingWord(text, pos) {
|
||||
let start = pos, end = pos;
|
||||
while (start > 0 && !_PROMPT_WEIGHT_DELIMITERS.has(text[start - 1])) start--;
|
||||
while (end < text.length && !_PROMPT_WEIGHT_DELIMITERS.has(text[end])) end++;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function _attachPromptWeightControl(textarea) {
|
||||
textarea.addEventListener("keydown", (e) => {
|
||||
if (!e.ctrlKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.key === "ArrowUp" ? 0.05 : -0.05;
|
||||
const text = textarea.value;
|
||||
let start = textarea.selectionStart;
|
||||
let end = textarea.selectionEnd;
|
||||
|
||||
if (start === end) {
|
||||
const range = _selectSurroundingParenBlock(text, start) || _selectSurroundingWord(text, start);
|
||||
start = range.start;
|
||||
end = range.end;
|
||||
}
|
||||
if (start === end) return;
|
||||
|
||||
const selected = text.substring(start, end);
|
||||
const m = selected.match(/^\((.*):(-?[\d.]+)\)$/s);
|
||||
const inner = m ? m[1] : selected;
|
||||
let weight = (m ? parseFloat(m[2]) : 1.0) + delta;
|
||||
weight = Math.round(weight * 100) / 100;
|
||||
|
||||
const replacement = weight === 1.0 ? inner : `(${inner}:${weight.toFixed(2)})`;
|
||||
textarea.value = text.substring(0, start) + replacement + text.substring(end);
|
||||
textarea.setSelectionRange(start, start + replacement.length);
|
||||
});
|
||||
}
|
||||
|
||||
export const comfyEditor = {
|
||||
models: {
|
||||
checkpoints: [],
|
||||
@@ -199,6 +337,13 @@ export const comfyEditor = {
|
||||
}
|
||||
},
|
||||
|
||||
// ComfyUI(Windows)は"\"区切りでLoraのenumを返すため、内部の"/"区切り表記と突き合わせて実際の表記に変換する
|
||||
resolveLoraName(name) {
|
||||
const normalized = name.replace(/\\/g, "/");
|
||||
const found = this.models.loras.find((n) => n.replace(/\\/g, "/") === normalized);
|
||||
return found || name;
|
||||
},
|
||||
|
||||
renderAll(analysis, workflow) {
|
||||
this.renderPromptTab(analysis, "wfm-gen-prompt-fields");
|
||||
this.renderImageTab(analysis, "wfm-gen-image-fields");
|
||||
@@ -221,21 +366,23 @@ export const comfyEditor = {
|
||||
<div class="wfm-form-group">
|
||||
<label>Positive Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1">
|
||||
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-apply">Apply</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
<textarea class="wfm-textarea" id="wfm-prompt-pos-text" rows="6">${positiveNodes[0]?.text || ""}</textarea>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Negative Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1">
|
||||
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-apply">Apply</button>
|
||||
</div>
|
||||
<textarea class="wfm-textarea" id="wfm-prompt-neg-text" rows="6">${negativeNodes[0]?.text || ""}</textarea>
|
||||
@@ -254,7 +401,7 @@ export const comfyEditor = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-prompt-pos-apply")?.addEventListener("click", () => {
|
||||
document.getElementById("wfm-prompt-pos-apply")?.addEventListener("click", (e) => {
|
||||
const nodeId = document.getElementById("wfm-prompt-pos-target")?.value;
|
||||
const text = document.getElementById("wfm-prompt-pos-text")?.value;
|
||||
if (nodeId && comfyUI.currentWorkflow?.[nodeId]) {
|
||||
@@ -262,6 +409,7 @@ export const comfyEditor = {
|
||||
const textKey = promptNode?.textKey || "text";
|
||||
comfyUI.currentWorkflow[nodeId].inputs[textKey] = text;
|
||||
_syncRawJson();
|
||||
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -276,6 +424,27 @@ export const comfyEditor = {
|
||||
}
|
||||
});
|
||||
|
||||
// Revert: テキストエリアを直前にApplyされたノードの現在値へ戻す
|
||||
document.getElementById("wfm-prompt-pos-revert")?.addEventListener("click", () => {
|
||||
const nodeId = document.getElementById("wfm-prompt-pos-target")?.value;
|
||||
const wfNode = comfyUI.currentWorkflow?.[nodeId];
|
||||
const ta = document.getElementById("wfm-prompt-pos-text");
|
||||
if (!nodeId || !wfNode || !ta) return;
|
||||
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
|
||||
const textKey = promptNode?.textKey || "text";
|
||||
ta.value = wfNode.inputs[textKey] ?? "";
|
||||
});
|
||||
|
||||
document.getElementById("wfm-prompt-neg-revert")?.addEventListener("click", () => {
|
||||
const nodeId = document.getElementById("wfm-prompt-neg-target")?.value;
|
||||
const wfNode = comfyUI.currentWorkflow?.[nodeId];
|
||||
const ta = document.getElementById("wfm-prompt-neg-text");
|
||||
if (!nodeId || !wfNode || !ta) return;
|
||||
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
|
||||
const textKey = promptNode?.textKey || "text";
|
||||
ta.value = wfNode.inputs[textKey] ?? "";
|
||||
});
|
||||
|
||||
// Track last focused prompt textarea for Paste button
|
||||
["wfm-prompt-pos-text", "wfm-prompt-neg-text"].forEach((taId) => {
|
||||
const ta = document.getElementById(taId);
|
||||
@@ -285,6 +454,7 @@ export const comfyEditor = {
|
||||
_lastPromptFocus = { id: taId, selStart: ta.selectionStart, selEnd: ta.selectionEnd };
|
||||
});
|
||||
});
|
||||
_attachPromptWeightControl(ta);
|
||||
});
|
||||
|
||||
// Embedding filter
|
||||
@@ -342,7 +512,6 @@ export const comfyEditor = {
|
||||
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
|
||||
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
|
||||
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
|
||||
{ label: "Text Encoder", key: "textEncoders", nodes: analysis.text_encoder_nodes, inputKey: "clip_name1" },
|
||||
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
|
||||
{
|
||||
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
|
||||
@@ -354,6 +523,7 @@ export const comfyEditor = {
|
||||
.map((s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
@@ -372,18 +542,20 @@ export const comfyEditor = {
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label>${s.label}</label>
|
||||
<input type="text" class="wfm-input wfm-model-filter" placeholder="Filter..." data-target="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}">Apply</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
|
||||
// Filter inputs
|
||||
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
|
||||
@@ -403,15 +575,20 @@ export const comfyEditor = {
|
||||
|
||||
// Apply buttons
|
||||
el.querySelectorAll(".wfm-model-apply").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
const key = btn.dataset.key;
|
||||
const inputKey = btn.dataset.input;
|
||||
let inputKey = btn.dataset.input;
|
||||
const select = document.getElementById(`wfm-model-${key}`);
|
||||
const targetSelect = document.getElementById(`wfm-model-${key}-target`);
|
||||
if (!select || !targetSelect) return;
|
||||
const value = select.value;
|
||||
const nodeId = targetSelect.value;
|
||||
if (nodeId && comfyUI.currentWorkflow?.[nodeId]) {
|
||||
// LoaderGGUF ノードは gguf_name キーを使う
|
||||
const ct = comfyUI.currentWorkflow[nodeId].class_type;
|
||||
if (key === "diffusionModels" && (ct === "LoaderGGUF" || ct === "LoaderGGUFAdvanced")) {
|
||||
inputKey = "gguf_name";
|
||||
}
|
||||
comfyUI.currentWorkflow[nodeId].inputs[inputKey] = value;
|
||||
// Apply extras (e.g. strength for Hypernetwork)
|
||||
el.querySelectorAll(`.wfm-model-extra[data-key="${key}"]`).forEach((ex) => {
|
||||
@@ -420,9 +597,13 @@ export const comfyEditor = {
|
||||
if (!isNaN(exVal)) comfyUI.currentWorkflow[nodeId].inputs[exInputKey] = exVal;
|
||||
});
|
||||
_syncRawJson();
|
||||
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Text Encoder セクションの非同期初期化
|
||||
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
|
||||
},
|
||||
|
||||
async renderLoraPane(analysis, containerId) {
|
||||
@@ -776,7 +957,7 @@ export const comfyEditor = {
|
||||
} else {
|
||||
const first = stackModels[0];
|
||||
if (first) {
|
||||
comfyUI.currentWorkflow[nodeId].inputs.lora_name = first;
|
||||
comfyUI.currentWorkflow[nodeId].inputs.lora_name = comfyEditor.resolveLoraName(first);
|
||||
comfyUI.currentWorkflow[nodeId].inputs.strength_model = _stackStrengths[first]?.m ?? 1.0;
|
||||
comfyUI.currentWorkflow[nodeId].inputs.strength_clip = _stackStrengths[first]?.c ?? 1.0;
|
||||
}
|
||||
@@ -883,7 +1064,7 @@ export const comfyEditor = {
|
||||
<label>Denoise</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? 1.0}" step="0.05" min="0" max="1">
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply">Apply</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;padding-left:14px;">
|
||||
@@ -927,7 +1108,7 @@ export const comfyEditor = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", () => {
|
||||
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
|
||||
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
@@ -940,6 +1121,7 @@ export const comfyEditor = {
|
||||
inputs.scheduler = document.getElementById("wfm-settings-scheduler")?.value;
|
||||
inputs.denoise = parseFloat(document.getElementById("wfm-settings-denoise")?.value) || 1.0;
|
||||
_syncRawJson();
|
||||
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-latent-apply")?.addEventListener("click", () => {
|
||||
@@ -1020,14 +1202,18 @@ export const comfyEditor = {
|
||||
if (!el) return;
|
||||
|
||||
const loadNodes = analysis.load_image_nodes || [];
|
||||
if (loadNodes.length === 0) {
|
||||
el.innerHTML = `<p class="wfm-placeholder">No LoadImage nodes found in workflow</p>`;
|
||||
const meoNodes = analysis.mask_editor_one_nodes || [];
|
||||
if (loadNodes.length === 0 && meoNodes.length === 0) {
|
||||
el.innerHTML = `<p class="wfm-placeholder">No LoadImage / Mask Editor One nodes found in workflow</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render up to 4 image input slots
|
||||
const slots = loadNodes.slice(0, 4);
|
||||
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
|
||||
const meoSlots = meoNodes.slice(0, 2);
|
||||
el.innerHTML = `
|
||||
${slots.length === 0 ? "" : `
|
||||
<div class="wfm-i2i-grid">
|
||||
${slots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
|
||||
@@ -1044,6 +1230,23 @@ export const comfyEditor = {
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-i2i-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
${node.mask_used ? `
|
||||
<div class="wfm-i2i-mask-section">
|
||||
<div class="wfm-i2i-mask-header">
|
||||
<span>Mask</span>
|
||||
<span class="wfm-i2i-mask-filename" id="wfm-i2i-mask-filename-${i}"></span>
|
||||
</div>
|
||||
<div class="wfm-i2i-preview-wrap wfm-i2i-mask-preview-wrap" id="wfm-i2i-mask-preview-wrap-${i}" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-i2i-mask-preview-${i}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone wfm-i2i-mask-drop-zone" id="wfm-i2i-mask-drop-${i}">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
Drop mask (white=inpaint) or click to select
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-i2i-mask-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
|
||||
@@ -1051,6 +1254,46 @@ export const comfyEditor = {
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`}
|
||||
${meoSlots.length === 0 ? "" : `
|
||||
<div class="wfm-i2i-grid" style="margin-top:12px;">
|
||||
${meoSlots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-meo-slot="${i}" data-node-id="${node.id}">
|
||||
<div class="wfm-i2i-slot-header">
|
||||
<span style="font-weight:600;font-size:12px;">ID:${node.id} ${node.title} (Mask Editor One)</span>
|
||||
</div>
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-meo-preview-wrap-${i}" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-meo-preview-${i}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-meo-drop-${i}">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
Drop image or click to select
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-meo-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-i2i-mask-section">
|
||||
<div class="wfm-i2i-mask-header">
|
||||
<span>Mask</span>
|
||||
<span class="wfm-i2i-mask-filename" id="wfm-meo-mask-filename-${i}"></span>
|
||||
</div>
|
||||
<div class="wfm-i2i-preview-wrap wfm-i2i-mask-preview-wrap" id="wfm-meo-mask-preview-wrap-${i}" style="display:none;">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-meo-mask-preview-${i}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone wfm-i2i-mask-drop-zone" id="wfm-meo-mask-drop-${i}">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
Drop mask (white=inpaint) or click to select
|
||||
<input type="file" accept="image/*" class="wfm-i2i-file" id="wfm-meo-mask-file-${i}" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-meo-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<span class="wfm-i2i-status" id="wfm-meo-status-${i}"></span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
|
||||
// Initialize each slot
|
||||
@@ -1062,7 +1305,13 @@ export const comfyEditor = {
|
||||
const filenameEl = document.getElementById(`wfm-i2i-filename-${i}`);
|
||||
const statusEl = document.getElementById(`wfm-i2i-status-${i}`);
|
||||
const dropZone = document.getElementById(`wfm-i2i-drop-${i}`);
|
||||
const maskFileInput = document.getElementById(`wfm-i2i-mask-file-${i}`);
|
||||
const maskDropZone = document.getElementById(`wfm-i2i-mask-drop-${i}`);
|
||||
const maskPreviewWrap = document.getElementById(`wfm-i2i-mask-preview-wrap-${i}`);
|
||||
const maskPreviewImg = document.getElementById(`wfm-i2i-mask-preview-${i}`);
|
||||
const maskFilenameEl = document.getElementById(`wfm-i2i-mask-filename-${i}`);
|
||||
let pendingFile = null;
|
||||
let pendingMaskFile = null;
|
||||
|
||||
const applyFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
@@ -1075,10 +1324,24 @@ export const comfyEditor = {
|
||||
statusEl.textContent = "";
|
||||
};
|
||||
|
||||
const applyMaskFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
pendingMaskFile = file;
|
||||
const url = URL.createObjectURL(file);
|
||||
maskPreviewImg.src = url;
|
||||
maskPreviewWrap.style.display = "";
|
||||
maskFilenameEl.textContent = file.name;
|
||||
applyBtn.disabled = false;
|
||||
statusEl.textContent = "";
|
||||
};
|
||||
|
||||
// File input change
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
});
|
||||
maskFileInput?.addEventListener("change", () => {
|
||||
if (maskFileInput.files.length > 0) applyMaskFile(maskFileInput.files[0]);
|
||||
});
|
||||
|
||||
// Drag & drop
|
||||
if (dropZone) {
|
||||
@@ -1095,14 +1358,39 @@ export const comfyEditor = {
|
||||
if (e.dataTransfer.files.length > 0) applyFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
if (maskDropZone) {
|
||||
maskDropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
maskDropZone.classList.add("drag-over");
|
||||
});
|
||||
maskDropZone.addEventListener("dragleave", () => {
|
||||
maskDropZone.classList.remove("drag-over");
|
||||
});
|
||||
maskDropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
maskDropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyMaskFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply button: upload to ComfyUI and set on node
|
||||
// (マスクが指定されている場合は画像+マスクをRGBA合成して1枚のPNGとしてアップロードする)
|
||||
applyBtn?.addEventListener("click", async () => {
|
||||
if (!pendingFile) return;
|
||||
if (!pendingFile && !pendingMaskFile) return;
|
||||
applyBtn.disabled = true;
|
||||
statusEl.textContent = "Uploading...";
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(pendingFile, pendingFile.name);
|
||||
let result;
|
||||
if (pendingMaskFile) {
|
||||
const baseImage = pendingFile || previewImg.src;
|
||||
if (!baseImage) throw new Error("No base image to apply mask to");
|
||||
const composedBlob = await _compositeImageWithMask(baseImage, pendingMaskFile);
|
||||
const fileName = (pendingFile?.name || node.image || "inpaint.png").replace(/\.[^.]+$/, "") + "_masked.png";
|
||||
const composedFile = new File([composedBlob], fileName, { type: "image/png" });
|
||||
result = await comfyUI.uploadImage(composedFile, fileName);
|
||||
} else {
|
||||
result = await comfyUI.uploadImage(pendingFile, pendingFile.name);
|
||||
}
|
||||
if (result.name) {
|
||||
// Update workflow node
|
||||
if (comfyUI.currentWorkflow?.[node.id]) {
|
||||
@@ -1122,6 +1410,220 @@ export const comfyEditor = {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
|
||||
meoSlots.forEach((node, i) => {
|
||||
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
|
||||
const maskFileInput = document.getElementById(`wfm-meo-mask-file-${i}`);
|
||||
const dropZone = document.getElementById(`wfm-meo-drop-${i}`);
|
||||
const maskDropZone = document.getElementById(`wfm-meo-mask-drop-${i}`);
|
||||
const previewWrap = document.getElementById(`wfm-meo-preview-wrap-${i}`);
|
||||
const previewImg = document.getElementById(`wfm-meo-preview-${i}`);
|
||||
const maskPreviewWrap = document.getElementById(`wfm-meo-mask-preview-wrap-${i}`);
|
||||
const maskPreviewImg = document.getElementById(`wfm-meo-mask-preview-${i}`);
|
||||
const maskFilenameEl = document.getElementById(`wfm-meo-mask-filename-${i}`);
|
||||
const applyBtn = el.querySelector(`.wfm-meo-apply[data-slot="${i}"]`);
|
||||
const statusEl = document.getElementById(`wfm-meo-status-${i}`);
|
||||
let pendingFile = null;
|
||||
let pendingMaskFile = null;
|
||||
|
||||
const updateApplyEnabled = () => { applyBtn.disabled = !(pendingFile && pendingMaskFile); };
|
||||
|
||||
const applyFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
pendingFile = file;
|
||||
previewImg.src = URL.createObjectURL(file);
|
||||
previewWrap.style.display = "";
|
||||
statusEl.textContent = "";
|
||||
updateApplyEnabled();
|
||||
};
|
||||
const applyMaskFile = (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
pendingMaskFile = file;
|
||||
maskPreviewImg.src = URL.createObjectURL(file);
|
||||
maskPreviewWrap.style.display = "";
|
||||
maskFilenameEl.textContent = file.name;
|
||||
statusEl.textContent = "";
|
||||
updateApplyEnabled();
|
||||
};
|
||||
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyFile(fileInput.files[0]);
|
||||
});
|
||||
maskFileInput?.addEventListener("change", () => {
|
||||
if (maskFileInput.files.length > 0) applyMaskFile(maskFileInput.files[0]);
|
||||
});
|
||||
|
||||
[[dropZone, applyFile], [maskDropZone, applyMaskFile]].forEach(([zone, handler]) => {
|
||||
if (!zone) return;
|
||||
zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drag-over"); });
|
||||
zone.addEventListener("dragleave", () => zone.classList.remove("drag-over"));
|
||||
zone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) handler(e.dataTransfer.files[0]);
|
||||
});
|
||||
});
|
||||
|
||||
applyBtn?.addEventListener("click", async () => {
|
||||
if (!pendingFile || !pendingMaskFile) return;
|
||||
applyBtn.disabled = true;
|
||||
statusEl.textContent = "Uploading...";
|
||||
try {
|
||||
await this.applyImageAndMaskToMaskEditorOneNode(pendingFile, pendingMaskFile, node.id);
|
||||
statusEl.textContent = "✓ Applied";
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
} catch (err) {
|
||||
statusEl.textContent = `✗ ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
applyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// opts.workflow/opts.analysis を渡すと comfyUI.currentWorkflow/currentAnalysis の代わりに
|
||||
// そちらへ書き込み、DOM更新は行わずアップロード後のファイル名だけを返す
|
||||
// (GenerateUIタブの表示状態には触れない。Chatペインの画像添付I2I連携から利用)。
|
||||
async applyImageToSlot(file, slotIndex = 0, opts = {}) {
|
||||
const workflow = opts.workflow || comfyUI.currentWorkflow;
|
||||
const analysis = opts.analysis || comfyUI.currentAnalysis;
|
||||
const loadNodes = analysis?.load_image_nodes || [];
|
||||
const node = loadNodes[slotIndex];
|
||||
if (!node) throw new Error("No LoadImage node at slot " + slotIndex);
|
||||
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
|
||||
if (workflow?.[node.id]) {
|
||||
workflow[node.id].inputs.image = result.name;
|
||||
}
|
||||
|
||||
if (opts.workflow) return result.name;
|
||||
|
||||
const previewWrap = document.getElementById(`wfm-i2i-preview-wrap-${slotIndex}`);
|
||||
const previewImg = document.getElementById(`wfm-i2i-preview-${slotIndex}`);
|
||||
const filenameEl = document.getElementById(`wfm-i2i-filename-${slotIndex}`);
|
||||
const statusEl = document.getElementById(`wfm-i2i-status-${slotIndex}`);
|
||||
const applyBtn = document.querySelector(`.wfm-i2i-apply[data-slot="${slotIndex}"]`);
|
||||
|
||||
if (previewImg) {
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
}
|
||||
if (filenameEl) filenameEl.textContent = result.name;
|
||||
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
|
||||
_syncRawJson();
|
||||
return result.name;
|
||||
},
|
||||
|
||||
// imageInput/maskInput: File/Blob いずれも可。マスクは白=インペイント対象領域のグレースケール想定。
|
||||
// 画像とマスクをRGBA合成し、1枚のPNGとして対象LoadImageノードへアップロードする
|
||||
// (ComfyUIネイティブのアルファ→MASK抽出に準拠。Image Edit「Inpaint」タブのRunボタンから利用)。
|
||||
// opts.workflow/opts.analysis を渡すと、comfyUI.currentWorkflow/currentAnalysis の代わりに
|
||||
// そのワークフローへ書き込む(GenerateUIタブの表示・DOM更新は行わない。専用ワークフロー実行用)。
|
||||
async applyImageAndMaskToSlot(imageInput, maskInput, slotIndex = 0, opts = {}) {
|
||||
const workflow = opts.workflow || comfyUI.currentWorkflow;
|
||||
const analysis = opts.analysis || comfyUI.currentAnalysis;
|
||||
const loadNodes = analysis?.load_image_nodes || [];
|
||||
const node = loadNodes[slotIndex];
|
||||
if (!node) throw new Error("No LoadImage node at slot " + slotIndex);
|
||||
|
||||
const composedBlob = await _compositeImageWithMask(imageInput, maskInput);
|
||||
const fileName = `inpaint_${Math.random().toString(36).slice(2, 10)}.png`;
|
||||
const composedFile = new File([composedBlob], fileName, { type: "image/png" });
|
||||
|
||||
const result = await comfyUI.uploadImage(composedFile, fileName);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
|
||||
if (workflow?.[node.id]) {
|
||||
workflow[node.id].inputs.image = result.name;
|
||||
}
|
||||
|
||||
if (opts.workflow) return result.name;
|
||||
|
||||
const previewWrap = document.getElementById(`wfm-i2i-preview-wrap-${slotIndex}`);
|
||||
const previewImg = document.getElementById(`wfm-i2i-preview-${slotIndex}`);
|
||||
const filenameEl = document.getElementById(`wfm-i2i-filename-${slotIndex}`);
|
||||
const statusEl = document.getElementById(`wfm-i2i-status-${slotIndex}`);
|
||||
const applyBtn = document.querySelector(`.wfm-i2i-apply[data-slot="${slotIndex}"]`);
|
||||
|
||||
if (previewImg) {
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
}
|
||||
if (filenameEl) filenameEl.textContent = result.name;
|
||||
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
|
||||
_syncRawJson();
|
||||
return result.name;
|
||||
},
|
||||
|
||||
// imageInput/maskInput: File/Blob いずれも可。マスクは白=インペイント対象領域のグレースケール想定。
|
||||
// Mask Editor One (comfyui-mask-editor-one) ノード向け。画像はサーバー側キャッシュへ
|
||||
// /mask_editor/store_image でPOSHし(node_idキー、bg_image_b64)、マスクは layer_data
|
||||
// ウィジェットへ「アルファチャンネル=mask値」のレイヤーとして書き込む。
|
||||
// opts.workflow を渡すと comfyUI.currentWorkflow の代わりにそのワークフローへ書き込む。
|
||||
async applyImageAndMaskToMaskEditorOneNode(imageInput, maskInput, nodeId, opts = {}) {
|
||||
const workflow = opts.workflow || comfyUI.currentWorkflow;
|
||||
const bgDataUrl = await _imageInputToDataURL(imageInput);
|
||||
const maskDataUrl = await _maskInputToAlphaDataURL(maskInput);
|
||||
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/mask_editor/store_image`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: String(nodeId), bg_image_b64: bgDataUrl }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to store image for Mask Editor One (node ${nodeId})`);
|
||||
|
||||
if (workflow?.[nodeId]) {
|
||||
workflow[nodeId].inputs.layer_data = JSON.stringify({
|
||||
layers: [{ visible: true, opacity: 1, operation: "add", imageData: maskDataUrl }],
|
||||
});
|
||||
}
|
||||
|
||||
if (!opts.workflow) _syncRawJson();
|
||||
},
|
||||
|
||||
// Inpaint Run から呼ばれるポジ/ネガプロンプト設定。
|
||||
// opts.workflow/opts.analysis を渡すと、GenerateUIタブの選択中ターゲット(DOM)に依存せず、
|
||||
// そのワークフロー内の最初の該当role prompt_nodeへ直接反映する(専用ワークフロー実行用)。
|
||||
setPromptText(role, text, opts = {}) {
|
||||
if (opts.workflow && opts.analysis) {
|
||||
const node = (opts.analysis.prompt_nodes || []).find((n) => n.role === role);
|
||||
if (node && opts.workflow[node.id]) {
|
||||
opts.workflow[node.id].inputs[node.textKey || "text"] = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const textareaId = role === "positive" ? "wfm-prompt-pos-text" : "wfm-prompt-neg-text";
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (textarea) textarea.value = text;
|
||||
this.syncToWorkflow();
|
||||
_syncRawJson();
|
||||
},
|
||||
|
||||
// Inpaint Run から呼ばれる grow_mask_by / denoise 設定。
|
||||
// ワークフロー内の最初の VAEEncodeForInpaint ノード / denoise を持つ最初の sampler ノードへ反映する
|
||||
// (複数のインペイントパイプラインが同一ワークフローに存在するケースは現状非対応)。
|
||||
// workflow/analysis を渡すと comfyUI.currentWorkflow/currentAnalysis の代わりにそちらへ書き込む。
|
||||
setInpaintParams({ growMaskBy, denoise, workflow, analysis } = {}) {
|
||||
const wf = workflow || comfyUI.currentWorkflow;
|
||||
const an = analysis || comfyUI.currentAnalysis;
|
||||
const encodeNode = an?.inpaint_encode_nodes?.[0];
|
||||
if (encodeNode && growMaskBy != null && wf?.[encodeNode.id]) {
|
||||
wf[encodeNode.id].inputs.grow_mask_by = growMaskBy;
|
||||
}
|
||||
|
||||
const samplerNode = (an?.sampler_nodes || []).find((n) => n.denoise !== undefined);
|
||||
if (samplerNode && denoise != null && wf?.[samplerNode.id]) {
|
||||
wf[samplerNode.id].inputs.denoise = denoise;
|
||||
}
|
||||
|
||||
if (!workflow) _syncRawJson();
|
||||
},
|
||||
|
||||
syncToWorkflow() {
|
||||
@@ -1207,3 +1709,100 @@ function _nodeOptions(nodes) {
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
|
||||
const section = containerEl.querySelector("#wfm-te-section");
|
||||
if (!section) return;
|
||||
|
||||
const nodes = analysis.text_encoder_nodes || [];
|
||||
if (nodes.length === 0) {
|
||||
section.innerHTML = `<label>Text Encoder</label><p class="wfm-placeholder">No text encoder node found</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const firstNode = nodes[0];
|
||||
const ct = firstNode.type;
|
||||
const isDual = ct === "DualCLIPLoader" || ct === "DualClipLoaderGGUF";
|
||||
const hasDevice = ct === "ClipLoaderGGUF" || ct === "DualClipLoaderGGUF";
|
||||
// CLIPLoader は clip_name、DualCLIPLoader は clip_name1 キーを使う
|
||||
const clip1Key = (ct === "CLIPLoader" || ct === "ClipLoaderGGUF") ? "clip_name" : "clip_name1";
|
||||
|
||||
// object_info から type 選択肢を取得
|
||||
let typeOptions = [];
|
||||
try {
|
||||
const info = await comfyUI.fetchObjectInfo(ct);
|
||||
const typeSpec = info?.[ct]?.input?.required?.type;
|
||||
if (typeSpec) {
|
||||
const first = typeSpec[0];
|
||||
if (Array.isArray(first)) typeOptions = first;
|
||||
else if (Array.isArray(typeSpec[1]?.values)) typeOptions = typeSpec[1].values;
|
||||
else if (Array.isArray(typeSpec[1]?.options)) typeOptions = typeSpec[1].options;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const currentClip1 = firstNode.clip_name1 || "";
|
||||
const currentClip2 = firstNode.clip_name2 || "";
|
||||
const currentType = firstNode.clip_type || typeOptions[0] || "";
|
||||
const currentDevice = firstNode.device || "default";
|
||||
|
||||
const targetOpts = nodes.map(n => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`).join("");
|
||||
const clip1Opts = textEncoders.map(m => `<option value="${m}" ${m === currentClip1 ? "selected" : ""}>${m}</option>`).join("");
|
||||
const clip2Opts = textEncoders.map(m => `<option value="${m}" ${m === currentClip2 ? "selected" : ""}>${m}</option>`).join("");
|
||||
const typeOpts = typeOptions.map(t => `<option value="${t}" ${t === currentType ? "selected" : ""}>${t}</option>`).join("");
|
||||
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
|
||||
|
||||
section.innerHTML = `
|
||||
<label>Text Encoder</label>
|
||||
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
|
||||
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
|
||||
${typeOptions.length > 0 ? `
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);width:50px;">Type</label>
|
||||
<select class="wfm-select" id="wfm-te-type">${typeOpts}</select>
|
||||
</div>` : ""}
|
||||
${hasDevice ? `
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);width:50px;">Device</label>
|
||||
<select class="wfm-select" id="wfm-te-device">${deviceOpts}</select>
|
||||
</div>` : ""}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-te-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-te-apply">Apply</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// フィルター(clip1 のみ)
|
||||
document.getElementById("wfm-te-filter")?.addEventListener("input", (e) => {
|
||||
const filter = e.target.value.toLowerCase();
|
||||
const sel = document.getElementById("wfm-te-clip1");
|
||||
if (!sel) return;
|
||||
const filtered = textEncoders.filter(m => m.toLowerCase().includes(filter));
|
||||
sel.innerHTML = filtered.map(m => `<option value="${m}">${m}</option>`).join("");
|
||||
});
|
||||
|
||||
// Apply
|
||||
document.getElementById("wfm-te-apply")?.addEventListener("click", () => {
|
||||
const nodeId = document.getElementById("wfm-te-target")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
|
||||
const clip1Val = document.getElementById("wfm-te-clip1")?.value;
|
||||
if (clip1Val) inputs[clip1Key] = clip1Val;
|
||||
|
||||
if (isDual) {
|
||||
const clip2Val = document.getElementById("wfm-te-clip2")?.value;
|
||||
if (clip2Val) inputs.clip_name2 = clip2Val;
|
||||
}
|
||||
|
||||
const typeVal = document.getElementById("wfm-te-type")?.value;
|
||||
if (typeVal) inputs.type = typeVal;
|
||||
|
||||
if (hasDevice) {
|
||||
const deviceVal = document.getElementById("wfm-te-device")?.value;
|
||||
if (deviceVal) inputs.device = deviceVal;
|
||||
}
|
||||
|
||||
_syncRawJson();
|
||||
});
|
||||
}
|
||||
|
||||
+311
-17
@@ -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 {
|
||||
@@ -37,7 +81,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
// Other uppercase types (MODEL, CLIP, etc.) are link inputs, skip
|
||||
@@ -51,7 +95,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +106,7 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
|
||||
/**
|
||||
* Get ordered widget input types for a node class (parallel to _getWidgetInputNames).
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", or "COMBO".
|
||||
* Returns type info: "INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", or "COMFY_DYNAMICCOMBO_V3".
|
||||
*/
|
||||
function _getWidgetInputTypes(objectInfo, classType) {
|
||||
const info = objectInfo[classType];
|
||||
@@ -77,7 +121,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +132,7 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +140,19 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the sub-input names for a DynamicCombo's currently-selected option.
|
||||
* objectInfo spec shape: ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, inputs: { required: {...} } }, ...] }]
|
||||
*/
|
||||
function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey) {
|
||||
const info = objectInfo[classType];
|
||||
const spec = info?.input?.required?.[widgetName] || info?.input?.optional?.[widgetName];
|
||||
const options = spec?.[1]?.options;
|
||||
if (!Array.isArray(options)) return [];
|
||||
const opt = options.find((o) => o.key === selectedKey) || options[0];
|
||||
return Object.keys(opt?.inputs?.required || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a widget value is a frontend-only extra (like "control_after_generate").
|
||||
* These values appear in widgets_values but are not in object_info inputs.
|
||||
@@ -123,10 +180,17 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
*
|
||||
* Strategy: expand internal nodes into the parent, redirect parent links through
|
||||
* the boundary ports, and convert internal links from object to array format.
|
||||
* Widget values in internal nodes are preserved as-is (they are synced at save time
|
||||
* by ComfyUI frontend). convertUiToApi handles widget mapping via object_info.
|
||||
*
|
||||
* Widget values: the outer subgraph node's own widgets_values hold the values actually
|
||||
* set via the subgraph's collapsed form (what the user sees/edits). The internal nodes'
|
||||
* widgets_values, stored in the subgraph template (definitions.subgraphs[].nodes), are
|
||||
* only stale defaults from when the template was authored — ComfyUI does NOT keep them
|
||||
* synced in saved workflow JSON. Un-linked outer widget inputs are therefore copied onto
|
||||
* the matching internal node's widgets_values (via objectInfo to get the correct index,
|
||||
* since the internal node's real class type is known). Linked outer inputs are left to
|
||||
* the "Redirect parent links" step above, which takes priority.
|
||||
*/
|
||||
function _flattenSubgraphs(workflow) {
|
||||
function _flattenSubgraphs(workflow, objectInfo) {
|
||||
const defs = workflow.definitions?.subgraphs;
|
||||
if (!defs || defs.length === 0) return workflow;
|
||||
|
||||
@@ -192,6 +256,12 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Redirect parent links ===
|
||||
// Track which {internal node, input slot} pairs end up resolved by a real outer
|
||||
// link after redirection — needed below to tell apart "boundary-only" links
|
||||
// (prompt/model-name ports that are actually widget values, see injection step)
|
||||
// from ports that truly carry a link end-to-end (e.g. width/height wired to a
|
||||
// ResolutionSelector node).
|
||||
const redirectedTargets = new Set();
|
||||
for (const link of wf.links) {
|
||||
if (link[3] === node.id) {
|
||||
const dstSlot = link[4];
|
||||
@@ -204,6 +274,7 @@ function _flattenSubgraphs(workflow) {
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
redirectedTargets.add(`${target.targetNodeId}:${target.targetSlot}`);
|
||||
}
|
||||
}
|
||||
if (link[1] === node.id) {
|
||||
@@ -217,12 +288,82 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Add internal nodes (remapped IDs) ===
|
||||
const remappedByOrigId = {};
|
||||
const normalizedNodeIds = new Set();
|
||||
for (const iNode of sgDef.nodes) {
|
||||
const remapped = JSON.parse(JSON.stringify(iNode));
|
||||
remapped.id = nodeIdRemap[iNode.id];
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format
|
||||
// (one entry per widget input, including linked ones), while convertUiToApi's
|
||||
// widget-mapping loop assumes the "modern" format (linked widget inputs have no
|
||||
// entry at all). Detect the full format by comparing lengths and strip the
|
||||
// entries belonging to inputs that are *actually* linked end-to-end (per
|
||||
// redirectedTargets above) so downstream mapping stays index-aligned. Boundary
|
||||
// links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// stripped — their value lives in the outer node's widgets_values instead and
|
||||
// is injected below.
|
||||
const allWidgetInputs = (remapped.inputs || []).filter((i) => i.widget);
|
||||
if (remapped.widgets_values && remapped.widgets_values.length === allWidgetInputs.length) {
|
||||
const linkedIdx = new Set();
|
||||
allWidgetInputs.forEach((inp, i) => {
|
||||
const slotIdx = remapped.inputs.indexOf(inp);
|
||||
if (redirectedTargets.has(`${remapped.id}:${slotIdx}`)) linkedIdx.add(i);
|
||||
});
|
||||
if (linkedIdx.size > 0) {
|
||||
remapped.widgets_values = remapped.widgets_values.filter((_, i) => !linkedIdx.has(i));
|
||||
normalizedNodeIds.add(remapped.id);
|
||||
}
|
||||
}
|
||||
|
||||
addedNodes.push(remapped);
|
||||
remappedByOrigId[iNode.id] = remapped;
|
||||
}
|
||||
|
||||
// === Inject outer subgraph-node widget values into internal nodes ===
|
||||
// Only un-linked widget inputs — linked ones are already redirected above.
|
||||
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
|
||||
outerWidgetInputs.forEach((inp, widgetIdx) => {
|
||||
if (inp.link != null) return;
|
||||
if (widgetIdx >= (node.widgets_values || []).length) return;
|
||||
const outerValue = node.widgets_values[widgetIdx];
|
||||
|
||||
const portIdx = (sgDef.inputs || []).findIndex((p) => p.name === inp.name);
|
||||
if (portIdx === -1) return;
|
||||
const target = inputPortTargets[portIdx];
|
||||
if (!target) return;
|
||||
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) return;
|
||||
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) return;
|
||||
|
||||
// When the internal node's widgets_values was normalized above (stripped of
|
||||
// linked-slot entries), the index must be computed against that same reduced
|
||||
// set of widget names — object_info's full name order (which still includes
|
||||
// e.g. width/height) would be off by however many entries were stripped.
|
||||
let iWidgetIdx;
|
||||
if (normalizedNodeIds.has(remappedNode.id)) {
|
||||
const remainingNames = (remappedNode.inputs || [])
|
||||
.filter((i) => i.widget)
|
||||
.filter((i) => {
|
||||
const slotIdx = remappedNode.inputs.indexOf(i);
|
||||
return !redirectedTargets.has(`${remappedNode.id}:${slotIdx}`);
|
||||
})
|
||||
.map((i) => i.name);
|
||||
iWidgetIdx = remainingNames.indexOf(targetInputDef.name);
|
||||
} else {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
iWidgetIdx = widgetNames.indexOf(targetInputDef.name);
|
||||
}
|
||||
if (iWidgetIdx === -1) return;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
});
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
for (const iLink of (sgDef.links || [])) {
|
||||
if (iLink.origin_id === -10 || iLink.target_id === -20) continue;
|
||||
@@ -253,8 +394,11 @@ export const comfyWorkflow = {
|
||||
detectFormat(workflow, filename) {
|
||||
if (!workflow || typeof workflow !== "object") return "unknown";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
|
||||
// App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
if (workflow.definitions || workflow.extra?.linearMode === true) return "app";
|
||||
// App format: simplified web-app-style UI (linearMode) or .app.json filename.
|
||||
// A workflow that merely *contains* subgraphs (definitions.subgraphs) is still
|
||||
// a normal node graph — convertUiToApi() flattens subgraphs before conversion,
|
||||
// so it's treated as "ui" rather than blocked as unsupported "app" format.
|
||||
if (workflow.extra?.linearMode === true) return "app";
|
||||
if (filename && /\.app\.json$/i.test(filename)) return "app";
|
||||
return "ui";
|
||||
}
|
||||
@@ -267,14 +411,30 @@ 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
|
||||
const flatWorkflow = _flattenSubgraphs(workflow);
|
||||
|
||||
const objectInfo = await _loadObjectInfo();
|
||||
|
||||
// Flatten subgraphs before conversion (needs objectInfo to correctly map outer
|
||||
// subgraph-node widget values onto internal nodes' widgets_values indices)
|
||||
const flatWorkflow = _flattenSubgraphs(workflow, objectInfo);
|
||||
|
||||
// Build link map: dstNode -> { dstSlot -> [srcNodeId, srcSlot] }
|
||||
const linkMap = {};
|
||||
for (const link of flatWorkflow.links) {
|
||||
@@ -282,10 +442,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 +474,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);
|
||||
}
|
||||
@@ -365,6 +540,22 @@ export const comfyWorkflow = {
|
||||
wIdx++;
|
||||
}
|
||||
}
|
||||
// DynamicCombo (e.g. SaveImageAdvanced's "format"): val is the selected
|
||||
// option's key (e.g. "png"). Per comfy_api/latest/_io.py
|
||||
// DynamicCombo._expand_schema_for_dynamic, the backend looks up flat
|
||||
// dot-prefixed keys ("format", "format.bit_depth", ...) in the raw
|
||||
// inputs dict and assembles them into a single dict itself at execution
|
||||
// time — it does NOT accept a pre-built dict under the "format" key.
|
||||
if (expectedType === "COMFY_DYNAMICCOMBO_V3") {
|
||||
inputs[name] = val;
|
||||
const subNames = _getDynamicComboSubNames(objectInfo, node.type, name, val);
|
||||
for (const subName of subNames) {
|
||||
if (wIdx >= widgets.length) break;
|
||||
inputs[`${name}.${subName}`] = widgets[wIdx];
|
||||
wIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// For COMBO inputs, validate the value against the available choices.
|
||||
// Dynamic COMBO options (e.g. Impact Pack's "Select to add Wildcard") may
|
||||
// differ between the saved workflow and the current ComfyUI instance
|
||||
@@ -379,6 +570,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];
|
||||
}
|
||||
}
|
||||
@@ -487,6 +686,8 @@ 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: [],
|
||||
@@ -686,6 +887,23 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
|
||||
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
|
||||
if (inputs.text_positive !== undefined) {
|
||||
@@ -713,6 +931,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({
|
||||
@@ -731,6 +965,20 @@ export const comfyWorkflow = {
|
||||
});
|
||||
}
|
||||
|
||||
// TextEncodeMageFlowEdit also carries width/height/batch_size directly
|
||||
// (it builds the latent internally instead of a separate EmptyLatentImage node).
|
||||
// width/height are commonly wired to a ResolutionSelector-style helper node
|
||||
// (array link ref) rather than a direct number — leave those as undefined so
|
||||
// the settings-tab UI falls back to its default instead of rendering a link ref.
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
// --- LoRA nodes ---
|
||||
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
|
||||
result.lora_nodes.push({
|
||||
@@ -793,19 +1041,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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -827,6 +1098,24 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
return result;
|
||||
},
|
||||
@@ -879,15 +1168,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;
|
||||
}
|
||||
|
||||
+207
-2
@@ -7,8 +7,10 @@ import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +43,89 @@ const API = {
|
||||
|
||||
export const FEEDER_GROUP = "__Feeder__";
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
|
||||
// Comic Creater側の「I2Iへ送る」ボタンから
|
||||
// iframe.contentWindow._wfmReceiveImageForI2I(blob, name, workflowData?, workflowFilename?) として呼ばれる。
|
||||
// 画像スロットへのセット自体は wfm-gallery-send-genui-image-btn のクリックハンドラ(Galleryの選択画像を送る版)と同一。
|
||||
// workflowData が渡された場合(Comic Creater側の設定タブでデフォルトワークフローが有効な時)は、
|
||||
// 画像をセットする前にそのワークフローを読み込む。
|
||||
window._wfmReceiveImageForI2I = async (blob, name, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (workflowData) {
|
||||
try {
|
||||
// iframeロード直後などモデルリスト未取得のままワークフローを読み込むと、
|
||||
// Model/生成UIタブのCheckpoint等ドロップダウンが空のままレンダリングされてしまう。
|
||||
// 接続確認・モデルリスト取得を保証してからワークフローを読み込む
|
||||
if (!comfyUI.connected) {
|
||||
await comfyUI.checkConnection();
|
||||
}
|
||||
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
|
||||
await comfyEditor.loadModelLists();
|
||||
}
|
||||
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
|
||||
} catch (e) {
|
||||
console.warn("[I2I] failed to load default workflow:", e);
|
||||
}
|
||||
}
|
||||
const file = new File([blob], name || "cc-image.png", { type: blob.type || "image/png" });
|
||||
document.querySelector('[data-tab="generate"]')?.click();
|
||||
document.querySelector('.wfm-gen-subtab-btn[data-subtab="input"]')?.click();
|
||||
document.querySelector('.wfm-input-inner-tab[data-input-tab="image"]')?.click();
|
||||
await comfyEditor.applyImageToSlot(file, 0);
|
||||
showToast(t("gallerySentGenUI"), "success");
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに合成画像+マスクを受け取り、Image Edit タブの
|
||||
// Inpaint機能をUIを開かずに直接実行して結果URLを返す(Inpaint連携)。
|
||||
// Comic Creater側の Image タブ「Inpaint」から
|
||||
// iframe.contentWindow._wfmReceiveInpaintRequest(imageBlob, maskBlob, params, workflowData?, workflowFilename?) として呼ばれる。
|
||||
// workflowData が渡された場合(Comic Creater側の設定タブでInpaint用デフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveInpaintRequest = async (imageBlob, maskBlob, params, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (workflowData) {
|
||||
if (!comfyUI.connected) {
|
||||
await comfyUI.checkConnection();
|
||||
}
|
||||
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
|
||||
await comfyEditor.loadModelLists();
|
||||
}
|
||||
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
|
||||
}
|
||||
return await window._wfmImageEditTab.runInpaintExternal(imageBlob, maskBlob, params);
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Image Edit タブの
|
||||
// Select I2I機能をUIを開かずに直接実行して結果URLを返す(I2I連携、マスク不要)。
|
||||
// Comic Creater側の Image タブ「Select→I2I」から
|
||||
// iframe.contentWindow._wfmReceiveI2IRunRequest(imageBlob, params, workflowData?, workflowFilename?) として呼ばれる。
|
||||
// workflowData が渡された場合(Comic Creater側のI2I設定でデフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveI2IRunRequest = async (imageBlob, params, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (workflowData) {
|
||||
if (!comfyUI.connected) {
|
||||
await comfyUI.checkConnection();
|
||||
}
|
||||
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
|
||||
await comfyEditor.loadModelLists();
|
||||
}
|
||||
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
|
||||
}
|
||||
return await window._wfmImageEditTab.runI2IExternal(imageBlob, params);
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ── ページング ────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
@@ -1471,11 +1556,110 @@ function bindEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
// 選択画像を GenerateUI Image タブへ送信
|
||||
document.getElementById("wfm-gallery-send-genui-image-btn")?.addEventListener("click", async () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
if (!comfyUI.currentAnalysis) {
|
||||
showToast(t("noWorkflowLoaded"), "info");
|
||||
return;
|
||||
}
|
||||
const loadNodes = comfyUI.currentAnalysis.load_image_nodes || [];
|
||||
if (loadNodes.length === 0) {
|
||||
showToast(t("galleryNoLoadImageNode"), "info");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(API.serveImage(state.selectedImage.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], state.selectedImage.filename, { type: blob.type || "image/png" });
|
||||
|
||||
// GenerateUI → Input subtab → Image inner tab へ切り替え
|
||||
document.querySelector('[data-tab="generate"]')?.click();
|
||||
document.querySelector('.wfm-gen-subtab-btn[data-subtab="input"]')?.click();
|
||||
document.querySelector('.wfm-input-inner-tab[data-input-tab="image"]')?.click();
|
||||
|
||||
await comfyEditor.applyImageToSlot(file, 0);
|
||||
showToast(t("gallerySentGenUI"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 選択画像を ComfyUI Comic Creator の選択コマ/オーバーレイへ送信
|
||||
// (ComfyUI Comic CreaterからこのGalleryタブがiframe埋め込みされている場合のみ表示・動作する)
|
||||
const sendCcBtn = document.getElementById("wfm-gallery-send-cc-btn");
|
||||
if (sendCcBtn) {
|
||||
let embedded = false;
|
||||
try {
|
||||
embedded = !!(window.parent && window.parent !== window);
|
||||
} catch (e) {
|
||||
embedded = false;
|
||||
}
|
||||
if (embedded) sendCcBtn.style.display = "";
|
||||
|
||||
sendCcBtn.addEventListener("click", () => {
|
||||
if (!state.selectedImage) {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
const url = API.serveImage(state.selectedImage.path);
|
||||
|
||||
// I2I送信元がImageタブだった場合はそちらへ画像を読み込ませる(_ccI2ITargetModeはComic Creater側が
|
||||
// 「I2Iへ送る」ボタン押下時にセットするフラグ。undefined/'layout'なら従来通りコマ/オーバーレイへ挿入)
|
||||
let targetMode = null;
|
||||
try {
|
||||
targetMode = window.parent?._ccI2ITargetMode || null;
|
||||
} catch (e) {
|
||||
targetMode = null;
|
||||
}
|
||||
if (targetMode === "image") {
|
||||
let imageTab = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent._ccImageTab?.loadFromUrl === "function") {
|
||||
imageTab = window.parent._ccImageTab;
|
||||
}
|
||||
} catch (e) {
|
||||
imageTab = null;
|
||||
}
|
||||
if (!imageTab) {
|
||||
showToast(t("galleryCCNotAvailable"), "info");
|
||||
return;
|
||||
}
|
||||
imageTab.loadFromUrl(url, (state.selectedImage.filename || "cc-image").replace(/\.[^.]+$/, ""));
|
||||
showToast(t("gallerySentCC"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
let insertFn = null;
|
||||
try {
|
||||
if (window.parent && window.parent !== window && typeof window.parent.insertImageFromUrl === "function") {
|
||||
insertFn = window.parent.insertImageFromUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
insertFn = null;
|
||||
}
|
||||
if (!insertFn) {
|
||||
showToast(t("galleryCCNotAvailable"), "info");
|
||||
return;
|
||||
}
|
||||
insertFn(url);
|
||||
showToast(t("gallerySentCC"), "success");
|
||||
});
|
||||
}
|
||||
|
||||
// 検索
|
||||
document.getElementById("wfm-gallery-search")?.addEventListener("input", (e) => {
|
||||
state.search = e.target.value;
|
||||
loadImages();
|
||||
});
|
||||
setupSearchClearBtn("wfm-gallery-search", "wfm-gallery-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
// ソート
|
||||
document.getElementById("wfm-gallery-sort")?.addEventListener("change", (e) => {
|
||||
@@ -1552,7 +1736,7 @@ function bindEvents() {
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
window.opener.wfmReceiveWorkflow(state.embeddedWorkflow);
|
||||
await navigator.clipboard.writeText(JSON.stringify(state.embeddedWorkflow, null, 2)).catch(() => {});
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
showToast(t("workflowSentToCanvasDirect"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
@@ -1704,4 +1888,25 @@ function bindEvents() {
|
||||
document.querySelectorAll(".wfm-gallery-lightbox").forEach(el => el.remove());
|
||||
}
|
||||
});
|
||||
|
||||
// Clear filters button
|
||||
const galleryClearBtn = document.getElementById("wfm-gallery-clear-filters-btn");
|
||||
if (galleryClearBtn) {
|
||||
galleryClearBtn.textContent = t("clearFilters");
|
||||
galleryClearBtn.addEventListener("click", () => {
|
||||
state.search = "";
|
||||
state.favoriteOnly = false;
|
||||
state.tagFilter = "";
|
||||
state.groupFilter = "";
|
||||
const searchInput = document.getElementById("wfm-gallery-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const tagFilter = document.getElementById("wfm-gallery-tag-filter");
|
||||
if (tagFilter) tagFilter.value = "";
|
||||
const groupFilter = document.getElementById("wfm-gallery-group-filter");
|
||||
if (groupFilter) groupFilter.value = "";
|
||||
const favBtn = document.getElementById("wfm-gallery-fav-btn");
|
||||
if (favBtn) favBtn.classList.remove("active");
|
||||
loadImages();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+368
-39
@@ -9,7 +9,7 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
|
||||
import { getSettings, readJsonStorage } from "./util.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
@@ -23,7 +23,7 @@ function getEagleSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
async function saveToEagle(imageUrl, name, tags = []) {
|
||||
async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
@@ -35,6 +35,12 @@ async function saveToEagle(imageUrl, name, tags = []) {
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -218,21 +224,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(t("unknownWorkflowFormat"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
comfyUI.currentWorkflow = apiWorkflow;
|
||||
await _applyDefaultCheckpointIfEnabled(apiWorkflow);
|
||||
comfyUI.currentAnalysis = comfyWorkflow.analyzeWorkflow(apiWorkflow);
|
||||
|
||||
// Render editor tabs
|
||||
@@ -247,6 +284,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) {
|
||||
@@ -260,7 +321,11 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
|
||||
refreshFeederNodeList();
|
||||
|
||||
showToast(t("workflowLoadedName", filename || ""), "success");
|
||||
if (ckptWarning) {
|
||||
showToast(ckptWarning, "error");
|
||||
} else {
|
||||
showToast(t("workflowLoadedName", filename || ""), "success");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -287,7 +352,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)" : "";
|
||||
@@ -457,6 +522,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に変換
|
||||
@@ -881,8 +954,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));
|
||||
@@ -1091,6 +1171,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1150,6 +1231,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();
|
||||
@@ -1157,6 +1250,7 @@ function initBatchTab() {
|
||||
_loadBatchLoraGroups();
|
||||
_loadPromptGroupsForBatch();
|
||||
_loadWorkflowGroupsForBatch();
|
||||
_rebuildStyleList();
|
||||
_renderBatchPreview();
|
||||
});
|
||||
}
|
||||
@@ -1193,6 +1287,101 @@ 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.
|
||||
@@ -1273,7 +1462,7 @@ async function _expandWildcardsInWorkflow(workflow) {
|
||||
// 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");
|
||||
@@ -1285,8 +1474,10 @@ async function _coreGenerate(silent = false) {
|
||||
if (progressBar) progressBar.style.width = "0%";
|
||||
if (progressText) progressText.textContent = "Starting...";
|
||||
|
||||
const workflowForGenerate = await _expandWildcardsInWorkflow({ ...comfyUI.currentWorkflow });
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
|
||||
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
|
||||
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
|
||||
const { images, seed, svgOutputs } = await comfyUI.generate(
|
||||
workflowForGenerate,
|
||||
{
|
||||
seedMode,
|
||||
@@ -1304,18 +1495,49 @@ async function _coreGenerate(silent = false) {
|
||||
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
// type=temp はプレビュー用の一時画像 (PreviewImage, SVG String Preview など) のため
|
||||
// 生成結果表示からは除外し、実際に保存された成果物のみを表示する
|
||||
const outputImages = images.filter((img) => img.type !== "temp");
|
||||
|
||||
if (svgOutputs?.length > 0) {
|
||||
// comfyui-tosvg の Save SVG String は images ではなく saved_svg/path で返すため、
|
||||
// images配列には(あれば)プレビュー用の一時PNGしか入らず実際の保存物と食い違う。
|
||||
// 実際に保存されたSVGファイルをそのままプレビューに表示する。
|
||||
// /view はSVGをセキュリティ上application/octet-streamで返すため使えず、
|
||||
// 代わりにGalleryタブの配信エンドポイント(絶対パス指定・正しいContent-Type)を使う。
|
||||
const svgUrl = (svg) => `/wfm/gallery/image/serve?path=${encodeURIComponent(svg.path)}`;
|
||||
if (resultImg && svgOutputs[0].path) {
|
||||
resultImg.src = svgUrl(svgOutputs[0]);
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (resultThumbs && svgOutputs.length > 1) {
|
||||
resultThumbs.innerHTML = "";
|
||||
svgOutputs.forEach((svg, i) => {
|
||||
if (!svg.path) return;
|
||||
const thumb = document.createElement("img");
|
||||
thumb.src = svgUrl(svg);
|
||||
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
|
||||
thumb.addEventListener("click", () => {
|
||||
resultImg.src = thumb.src;
|
||||
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
|
||||
thumb.classList.add("active");
|
||||
});
|
||||
resultThumbs.appendChild(thumb);
|
||||
});
|
||||
}
|
||||
} else if (outputImages.length > 0) {
|
||||
const blob = await comfyUI.getImageBlob(outputImages[0]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (resultImg) {
|
||||
resultImg.src = url;
|
||||
resultImg.style.display = "block";
|
||||
}
|
||||
|
||||
if (resultThumbs && images.length > 1) {
|
||||
if (resultThumbs && outputImages.length > 1) {
|
||||
resultThumbs.innerHTML = "";
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
|
||||
for (let i = 0; i < outputImages.length; i++) {
|
||||
const b = i === 0 ? blob : await comfyUI.getImageBlob(outputImages[i]);
|
||||
const u = i === 0 ? url : URL.createObjectURL(b);
|
||||
const thumb = document.createElement("img");
|
||||
thumb.src = u;
|
||||
@@ -1330,18 +1552,28 @@ async function _coreGenerate(silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
if (getEagleSettings().autoSave && images.length > 0) {
|
||||
for (const img of images) {
|
||||
if (getEagleSettings().autoSave && outputImages.length > 0) {
|
||||
for (const img of outputImages) {
|
||||
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
saveToEagle(viewUrl, img.filename);
|
||||
saveToEagle(viewUrl, img.filename, [], img);
|
||||
}
|
||||
}
|
||||
|
||||
if (getEagleSettings().autoSave && svgOutputs?.length > 0) {
|
||||
// comfyui-tosvg の "Save SVG String" は絶対パスを直接返すため、/view を介さず
|
||||
// サーバー側でそのパスを検証した上で addFromPath させる
|
||||
for (const svg of svgOutputs) {
|
||||
saveToEagle("", svg.filename, [], { filename: svg.filename, localPath: svg.path });
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
|
||||
saveGeneratedImagesMeta(images, { ...baseWorkflow }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!silent) showToast(t("generationComplete"), "success");
|
||||
|
||||
return { images, seed };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -1409,10 +1641,7 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
if (_ckptBatch.aborted) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,20 +1666,89 @@ async function _runBatchGenerate() {
|
||||
if (loraNodes.length === 0) { showToast(t("modelsGenUINoNode", "LoRA"), "error"); return; }
|
||||
const list = [..._getSelectedLoraGroupItems()];
|
||||
if (list.length === 0) { showToast(t("batchNoneSelected", "LoRAs"), "error"); return; }
|
||||
await _runBatchLoop(list, (loraName) => {
|
||||
for (const node of loraNodes) {
|
||||
if (!comfyUI.currentWorkflow?.[node.id]) continue;
|
||||
if (node.is_lora_manager) {
|
||||
// 通常の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": {
|
||||
@@ -1519,6 +1817,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,14 +1836,20 @@ async function _runBatchGenerate() {
|
||||
// Generate entry point
|
||||
// ============================================
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!comfyUI.currentWorkflow) {
|
||||
// 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");
|
||||
@@ -1544,18 +1860,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(t("generationError", err.message), "error");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -1616,7 +1934,15 @@ export async function initGenerateTab() {
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
@@ -1835,6 +2161,9 @@ export async function initGenerateTab() {
|
||||
// Feeder tab
|
||||
await initFeederTab();
|
||||
|
||||
// Style dropdown
|
||||
await _loadStyles();
|
||||
|
||||
// Auto-connect on init
|
||||
const connected = await comfyUI.checkConnection();
|
||||
updateStatus(connected);
|
||||
|
||||
+433
-42
@@ -17,6 +17,7 @@ const LANGUAGES = {
|
||||
test: "Test",
|
||||
copy: "Copy",
|
||||
close: "Close",
|
||||
clearFilters: "✕ Clear",
|
||||
error: "Error",
|
||||
|
||||
// -- Tabs --
|
||||
@@ -91,6 +92,7 @@ const LANGUAGES = {
|
||||
sendToCanvas: "Send to Canvas",
|
||||
copyAndSendCanvas: "Copy & Send Canvas",
|
||||
workflowSentToCanvas: "Workflow ready — drag 'Workflow Studio Library' title to canvas",
|
||||
workflowSentToCanvasDirect: "Workflow sent to canvas",
|
||||
comfyUILoadTimeout: "ComfyUI load timeout",
|
||||
popupBlocked: "Popup blocked. Please allow popups for this site.",
|
||||
setAsDefault: "Set as Default",
|
||||
@@ -115,6 +117,7 @@ const LANGUAGES = {
|
||||
deleteConfirm: (name) => `Delete "${name}"?`,
|
||||
summaryGenerated: "Summary generated",
|
||||
summarizeError: "Summarize error",
|
||||
summarizeOllamaOnly: "Summarize only supports the Ollama backend. Switch backend in Settings.",
|
||||
copied: "Copied",
|
||||
|
||||
// -- GenerateUI --
|
||||
@@ -205,9 +208,15 @@ const LANGUAGES = {
|
||||
unknownWorkflowFormat: "Unknown workflow format",
|
||||
generationComplete: "Generation complete",
|
||||
generationError: (msg) => `Generation error: ${msg}`,
|
||||
defaultCheckpointApplied: (name) => `Default checkpoint applied: ${name}`,
|
||||
checkpointSubstitutedWarning: (names) => `Checkpoint not found in this workflow, silently replaced with another model: ${names}. Please check the Model tab.`,
|
||||
rawJsonBypassNote: (list) => `Bypassed (excluded, wires reconnected): ${list}`,
|
||||
rawJsonMutedNote: (list) => `Muted (excluded): ${list}`,
|
||||
batchNoneSelected: (what) => `No ${what} selected`,
|
||||
batchItemFailed: (i, total, msg) => `[${i}/${total}] Failed: ${msg}`,
|
||||
batchStopped: (completed, failed) => `Batch stopped — ${completed} completed, ${failed} failed`,
|
||||
batchComplete: (completed, total, failed) => failed > 0 ? `Batch complete: ${completed}/${total} (${failed} failed)` : `Batch complete: ${completed}/${total}`,
|
||||
batchLoraMissing: (count, total, sample) => `${count}/${total} selected LoRAs are not recognized by ComfyUI yet (e.g. ${sample}). Click Refresh in ComfyUI or restart the server, then try again.`,
|
||||
connectedToComfyUI: "Connected to ComfyUI",
|
||||
failedToConnect: "Failed to connect",
|
||||
modelListsRefreshed: "Model lists refreshed",
|
||||
@@ -353,6 +362,7 @@ const LANGUAGES = {
|
||||
modelsGenUIUnsupported: "This model type cannot be set in GenerateUI",
|
||||
modelsGenUINoWorkflow: "No workflow loaded in GenerateUI",
|
||||
modelsGenUINoNode: (type) => `No ${type} node found in current workflow`,
|
||||
revertPromptTitle: "Revert to last applied prompt",
|
||||
modelsCopyName: "Click to copy model name",
|
||||
modelsCopiedName: "Model name copied",
|
||||
modelsFilePath: "File Path",
|
||||
@@ -467,6 +477,10 @@ const LANGUAGES = {
|
||||
galleryOutputDirLabel: "Output Folder Path",
|
||||
galleryOutputDirHint: "Set the ComfyUI output folder to browse in the Gallery tab. Leave blank to use ComfyUI's default output directory.",
|
||||
gallerySelectImageFirst: "Please select an image first",
|
||||
galleryNoLoadImageNode: "No LoadImage node in current workflow",
|
||||
gallerySentGenUI: "Sent to GenerateUI Image tab",
|
||||
galleryCCNotAvailable: "Open this Gallery from ComfyUI Comic Creator to use Send CC",
|
||||
gallerySentCC: "Sent to ComfyUI Comic Creator",
|
||||
galleryNoEmbeddedWorkflow: "No ComfyUI workflow embedded in this image.",
|
||||
galleryBulkSelected: "selected",
|
||||
galleryBulkDeselectAll: "Deselect All",
|
||||
@@ -480,6 +494,15 @@ const LANGUAGES = {
|
||||
galleryBulkMoveTo: "Move To...",
|
||||
galleryBulkExport: "Export",
|
||||
galleryBulkDelete: "Delete File",
|
||||
nodesBulkSelected: "selected",
|
||||
nodesBulkDeselect: "Deselect All",
|
||||
nodesBulkAddToGroup: "Select Group",
|
||||
nodesBulkGroupAdd: "Add to Group",
|
||||
nodesBulkGroupRemove: "Remove from Group",
|
||||
nodesBulkNewGroup: "New Group Name",
|
||||
nodesBulkCreateAdd: "Create & Add",
|
||||
nodesBulkFavAll: "★ Favorite All",
|
||||
nodesBulkUnfavAll: "☆ Unfavorite All",
|
||||
downloadStarted: "Download started",
|
||||
exportCompleted: "Export completed. ZIP file is downloading.",
|
||||
|
||||
@@ -584,6 +607,20 @@ const LANGUAGES = {
|
||||
aiStatusChatting: "Thinking...",
|
||||
aiToastChatFailed: "Chat failed: ",
|
||||
aiToastWcNoName: "Please enter a wildcard name",
|
||||
aiStatusGeneratingImage: "Generating image...",
|
||||
aiChatNoWorkflowLoaded: "No workflow loaded in GenerateUI",
|
||||
aiChatGenerateNotReady: "GenerateUI is not ready yet",
|
||||
aiChatNoImageProduced: "Generation completed but no image was produced",
|
||||
aiToastImageGenFailed: "Image generation failed: ",
|
||||
aiChatSaveSvgToGallery: "Save to Gallery",
|
||||
aiChatSvgSaved: "SVG saved to gallery",
|
||||
aiChatSvgSaveFailed: "Failed to save SVG: ",
|
||||
aiChatImgGenToggle: "Allow image generation",
|
||||
aiSettingsChatGenTitle: "Chat Image Generation",
|
||||
aiSettingsChatGenDedicated: "Use dedicated workflow",
|
||||
aiSettingsChatI2IGenTitle: "Chat I2I Generation",
|
||||
aiChatAttachmentLabel: "Image attached",
|
||||
aiChatNoLoadImageNode: "No LoadImage node found in the target workflow for image-to-image",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "Help",
|
||||
@@ -600,21 +637,24 @@ const LANGUAGES = {
|
||||
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, send to canvas, load in GenerateUI, set as default, delete",
|
||||
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
|
||||
helpGen1: "Connect/disconnect ComfyUI and check connection status",
|
||||
helpGen2: "Load workflows from Workflow tab (App format .app.json not supported); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
|
||||
helpGen2: "Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; Save button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default",
|
||||
helpGen3: "5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing",
|
||||
helpGen4: "Input tab: inner tabs — Prompt and Image (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused)",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, UNET, TextEncoder, Hypernetwork (with Strength field) selectors with filter",
|
||||
helpGen4: "Input tab: inner tabs — Prompt and Image (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a ↺ button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust its emphasis weight in (text:weight) syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter",
|
||||
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)",
|
||||
helpGen8: "Seed control: random, fixed, increment, decrement",
|
||||
helpGen9: "Generate, progress display, stop, and view results",
|
||||
helpGen10: "Eagle integration: auto-save generated images to Eagle",
|
||||
helpGen10: "Eagle integration: auto-save generated images to Eagle (SVG output supported too — both the standard SaveSVGNode and comfyui-tosvg's Save SVG String node)",
|
||||
helpGen11: "Batch tab — enable one batch type at a time by checking the checkbox in the Batch Queue column header; Generate then runs through all queued items sequentially with Pause/Resume and Stop controls",
|
||||
helpGen12: "Batch tab — Left pane has 3 tabs: Checkpoints (folder tree with Filter/All/None; (root) header checkbox selects all), Sampler (KSampler sampler_name list with (root) checkbox), Scheduler (KSampler scheduler list with (root) checkbox); All/None toolbar buttons also available",
|
||||
helpGen13: "Batch tab — 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 header to select all its members, or expand ▶ to select individually",
|
||||
helpGen14: "Batch tab — Right pane (Batch Queue): 6 columns — Checkpoint, Lora, Prompt, Workflow, Sampler, Scheduler; each column has an enable checkbox (radio behavior: only one at a time); count shown per column",
|
||||
helpGen15: "Sampler/Scheduler batch: select samplers or schedulers in the left pane, enable the column in Batch Queue, then Generate to iterate through each value on the KSampler node; lists are fetched from ComfyUI (connection required)",
|
||||
helpGen16: "SPA-side wildcard expansion (A1111-style): 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",
|
||||
helpGen17: "Style batch: select styles from the Style tab in the left pane, enable the Style column in Batch Queue, then Generate — each selected style is applied in sequence to the positive and negative prompts; applied to workflow copies so the loaded workflow is not modified; style files are re-read on each page load from user/default/Workflow-Studio/style/*.json",
|
||||
helpGen18: "Alt+Click any Apply button (Positive Prompt / Checkpoint / KSampler) to Apply & Generate in one step — applies the parameter to the workflow then immediately queues a generation; only fires when the target node exists in the loaded workflow",
|
||||
helpGen19: "Lora batch: for each 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; the prompt is automatically restored to its 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 is shown (Refresh or restart ComfyUI)",
|
||||
helpFeederTitle: "GenerateUI Tab — Feeder subtab",
|
||||
helpFeederDesc: "Two modes switchable via the [Image Loop] / [Gallery] toggle at the top of the left pane.",
|
||||
helpFeederImgloopTitle: "Image Loop mode",
|
||||
@@ -637,7 +677,7 @@ const LANGUAGES = {
|
||||
helpFeederGal5: "__Feeder__ group: reserved group pre-created automatically; cannot be renamed or deleted; add images to it from the Gallery tab using the F overlay button on each thumbnail",
|
||||
helpFeederGal6: "WFS_GalleryFeeder canvas controls: the node also has Run / Stop / After Gen widgets added directly on the ComfyUI canvas — these work the same as the WFM UI controls and allow running the gallery feeder loop without opening WFM",
|
||||
helpSearchPlaceholder: "Search help...",
|
||||
helpPrompt1: "AI assistant (Ollama / LM Studio): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
|
||||
helpPrompt1: "AI assistant (Ollama / LM Studio / Lemonade): model selection, chat for prompt consultation, image attachment (vision models); backend and URL configured in Settings tab",
|
||||
helpPrompt2: "Apply AI responses to GenerateUI prompts",
|
||||
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
|
||||
helpPrompt4: "Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)",
|
||||
@@ -650,7 +690,7 @@ const LANGUAGES = {
|
||||
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
|
||||
helpSettings3: "Workflow data folder path",
|
||||
helpSettings4: "ComfyUI connection URL and test",
|
||||
helpSettings5: "AI Assistant (Prompt Tab): backend (Ollama / LM Studio), API URL, default model, and connection test",
|
||||
helpSettings5: "AI Assistant (Prompt Tab): backend (Ollama / LM Studio / Lemonade), API URL, default model, and connection test",
|
||||
helpSettings6: "Default workflow setting",
|
||||
helpSettings7: "Eagle integration URL and auto-save toggle",
|
||||
helpSettings8: "Data Management: export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import to restore. After importing, restart ComfyUI.",
|
||||
@@ -665,6 +705,7 @@ const LANGUAGES = {
|
||||
helpNodes4: "Create, rename, delete node groups and assign/remove nodes",
|
||||
helpNodes5: "Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management",
|
||||
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
|
||||
helpNodes7: "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",
|
||||
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching",
|
||||
helpModels2: "Thumbnail and Table view modes with pagination; Table view shows Type and Base Model columns sourced from CivitAI data alongside filename, subdirectory, and extension; column headers are clickable to sort — click once for ascending (▲), again for descending (▼), a third time to clear the sort; the E/D column sorts by enable/disable status",
|
||||
helpModels3: "Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)",
|
||||
@@ -692,6 +733,9 @@ const LANGUAGES = {
|
||||
helpGallery12: "Metadata button (green, in the detail panel tab bar): opens the selected image in the Metadata tab for full embedded-data inspection; Alt+click on any image thumbnail has the same effect",
|
||||
helpGallery13: "Load GenUI button (blue, in the detail panel tab bar): loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab and switches to it; shows a warning toast if no workflow is embedded or the format is not supported",
|
||||
helpGallery14: "Tagger button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging",
|
||||
helpGallery15: "F button (cyan overlay, top-left of each thumbnail): toggle the image in/out of the __Feeder__ group with a single click; cyan and always visible when in group, visible on hover only when not; quickly queue images for the Feeder tab Gallery mode",
|
||||
helpGallery16: "Image Edit button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1",
|
||||
helpGallery17: "Send GenUI Image button (toolbar, next to Image Edit): uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded first",
|
||||
|
||||
// -- Tagger Tab --
|
||||
helpTaggerTitle: "Tagger Tab",
|
||||
@@ -721,15 +765,95 @@ const LANGUAGES = {
|
||||
helpSidepanel13: "I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF, QuadrupleCLIPLoader, and Lora Loader (LoraManager) node types",
|
||||
helpSidepanel14: "Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center",
|
||||
helpSidepanel15: "Information tab — LoRA sub-tab: detects LoRAs from LoraLoader, LoraLoaderModelOnly, and Lora Loader (LoraManager) nodes (API format supported); drag individual LoRA to place LoraLoader; \"Multiple LORA\" section places all LoRAs in a single Lora Loader (LoraManager) node with LoRA syntax pre-filled; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy",
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
|
||||
helpSidepanel17: "Send to Canvas: clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab) sends the workflow directly to the ComfyUI canvas — both UI and API formats are supported; if Workflow Studio was not opened from the ComfyUI toolbar (window.opener unavailable), UI-format workflows fall back to title drag (title turns blue with a green ●)",
|
||||
helpAiTitle: "AI Tab (A)",
|
||||
helpAi1: "4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously",
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line",
|
||||
helpAi5: "Settings pane: choose backend (Ollama / LM Studio), set API URL, test connection, select model, configure Free language names for translation",
|
||||
helpAi6: "Settings are saved to localStorage and shared with the Library panel's A tab",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image (\"generate a cat picture\", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the \"Allow image generation\" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button",
|
||||
helpAi5: "Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names for translation",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage",
|
||||
helpAi7: "Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a \"Save to Gallery\" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support",
|
||||
helpImageEditTitle: "Image Edit Tab",
|
||||
helpImageEdit1: "Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite",
|
||||
helpImageEdit2: "Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas",
|
||||
helpImageEdit3: "New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1",
|
||||
helpImageEditToolsTitle: "Tools",
|
||||
helpImageEdit4: "Select (V): click to select a layer; drag to move; drag corner handles to resize; drag the circle above the top edge to rotate; double-click a text layer to re-edit its content; Tool Options bar shows Flip H / Flip V / Rotate field",
|
||||
helpImageEdit5: "Draw (B): freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode; Properties pane includes a MASK EDITOR ONE (COLOR) section for ABR image brushes — always visible, header text is green when brushes are available, gray when none are imported yet; Select is disabled until at least one brush exists",
|
||||
helpImageEdit6: "Text (T): click on the canvas to place a text input overlay; configure font family, size, bold/italic, align, color in the Tool Options bar; click OK to place the text as an exact-size Text layer; double-click an existing text layer with the Select tool to re-edit",
|
||||
helpImageEdit7: "Shape (S): draw geometric shapes onto the canvas; each committed shape becomes an independent draw layer; Tool Options: Shape type (Rect / Ellipse / Line / FreeLine), Rounded checkbox (Rect / Ellipse only), Fill color + None toggle (hidden for Line / FreeLine), Stroke color + width + None toggle (None hidden for Line / FreeLine where stroke is always active), Opacity slider, ↩ Undo button; drag to draw — a blue dashed outline previews the shape; release to commit",
|
||||
helpImageEditLayerTitle: "Layer Panel",
|
||||
helpImageEdit8: "Layers are listed from front (top) to back (bottom); click a layer row to make it active",
|
||||
helpImageEdit9: "👁 / 🚫 button: toggle layer visibility",
|
||||
helpImageEdit10: "🔒 / 🔓 button: toggle layer lock — a locked layer cannot be moved, resized, or rotated (orange bounding box is shown); Layer 1 is locked automatically when the first image is loaded to prevent accidental moves",
|
||||
helpImageEdit11: "Opacity slider at the bottom adjusts the active layer's transparency",
|
||||
helpImageEdit12: "Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer",
|
||||
helpImageEdit13: "Delete key: removes the selected layer (only when 2+ layers exist)",
|
||||
helpImageEditTextTitle: "Text Object Quality",
|
||||
helpImageEdit14: "Text layers are sized exactly to the measured text bounding box — no extra whitespace",
|
||||
helpImageEdit15: "When a text layer is resized with the Select tool, the canvas is regenerated at the new display size so the text remains sharp (no blurring)",
|
||||
helpImageEdit16: "Re-editing a text layer (double-click) resets it to native size; re-scale afterward if needed",
|
||||
helpImageEditExportTitle: "Export & Navigation",
|
||||
helpImageEdit17: "Save PNG: composites all visible layers and downloads the result as a PNG file",
|
||||
helpImageEdit18: "Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root",
|
||||
helpImageEdit19: "Send to ComfyUI: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes",
|
||||
helpImageEdit20: "Canvas navigation: scroll wheel to zoom, Space + drag to pan",
|
||||
helpImageEdit21: "Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)",
|
||||
helpImageEditKbdTitle: "Image Edit Keyboard Shortcuts",
|
||||
helpImageEdit22: "V: Select tool",
|
||||
helpImageEdit23: "B: Draw tool",
|
||||
helpImageEdit24: "T: Text tool",
|
||||
helpImageEdit25: "S: Shape tool",
|
||||
helpImageEdit26: "Delete: Delete selected layer (requires 2+ layers)",
|
||||
helpImageEdit27: "Ctrl+Z / Ctrl+Y: Undo / Redo",
|
||||
helpImageEdit28: "Space + drag: Pan canvas",
|
||||
helpImageEdit29: "Mouse wheel: Zoom in / out",
|
||||
helpImageEdit30: "Double-click (Select tool on text layer): Re-edit text",
|
||||
helpImageEdit7b: "Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details",
|
||||
helpImageEdit7c: "BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details",
|
||||
helpImageEdit7d: "Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click \"M\" in the Layers header to add a mask layer; see the Mask Tool card below for details",
|
||||
helpImageEdit7e: "Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; \"Use dedicated workflow\" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI",
|
||||
helpImageEdit8b: "\"M\" button in the Layers header: adds a new mask layer above the current layer and automatically switches to the Mask tool",
|
||||
helpImageEdit12: "Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer, ⬚ = mask layer",
|
||||
helpImageEditMaskTitle: "Mask Tool (🎭)",
|
||||
helpImageEditMask1: "Add a mask layer: click the \"M\" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically",
|
||||
helpImageEditMask2: "Clicking a mask layer in the layer list automatically switches to the Mask tool",
|
||||
helpImageEditMask3: "Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected",
|
||||
helpImageEditMask4: "Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)",
|
||||
helpImageEditMask5: "✂ button on a mask layer row: toggles the layer as a clipping mask — when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied when saving PNG / Gallery / ComfyUI",
|
||||
helpImageEditMask6: "SAM3 (Mask Editor One required): text-prompt segmentation powered by the SAM3 model; enter a description of the object to segment (e.g. \"cat\", \"person\"), set Max (maximum mask candidates: 3/6/9/12), then click Segment",
|
||||
helpImageEditMask7: "SAM3 workflow: after Segment, candidate masks appear as thumbnails in the Properties pane — click to select (blue border + ✓); multiple masks can be selected; click Apply Selected (N) to apply them all; Mode (Add / Erase) controls how masks are merged into the current mask layer; a new mask layer is created automatically if the active layer is not a mask",
|
||||
helpImageEditMask8: "ABR Brush Library (Mask Editor One required, Paint mode only): click Select in the MASK EDITOR ONE section to open the brush picker — browse folders on the left, click a thumbnail to apply; \"Round Brush (default)\" restores the circle brush; brushes are shared with Mask Editor One and stored in its brushes/ folder",
|
||||
helpImageEditMask9: "Color sub-tool: click anywhere on the canvas to flood-select pixels similar in color to the clicked point — Properties pane: Mode (Add / Subtract), Tolerance (0–255, color similarity threshold), Feather % (0–100, soft edge falloff within tolerance range)",
|
||||
helpImageEditMask10: "Alpha sub-tool: extract the transparency of the image layers as a mask — Properties pane: Threshold (pixels with alpha ≥ threshold become full mask; 0 = all alpha included), Invert (reverse the extracted result), Extract Alpha button applies the operation; works on a composite of all visible non-mask layers",
|
||||
helpImageEditMask11: "Text sub-tool: click on the canvas to open a text stamp overlay; enter text, configure Font / Size / Bold / Italic / Align, choose Mode (Add = white stamp / Erase = cut mask), then click Stamp or press Ctrl+Enter; multi-line text supported (newline in textarea)",
|
||||
helpImageEditMask12: "Vector sub-tool: click to place control points connected by a Catmull-Rom spline; click the first point again (or press Enter) to close and fill the polygon; Backspace removes the last point; Esc resets all points; Properties pane shows current point count and Mode (Add / Erase)",
|
||||
helpImageEditMask13: "Shape sub-tool: drag to draw a rectangle or ellipse mask; hold Shift for a perfect square or circle; a blue dashed outline previews the shape while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)",
|
||||
helpImageEditBlurTitle: "Blur Tool (≈)",
|
||||
helpImageEditBlur1: "Whole Blur: applies a Gaussian blur to the entire active layer — adjust the intensity slider (1–50 px) and click Apply",
|
||||
helpImageEditBlur2: "Whole Mosaic: applies a pixelation mosaic to the entire active layer — adjust the block size slider (5–100 px) and click Apply",
|
||||
helpImageEditBlur3: "Rect Blur: click the toggle to enter rect mode (button turns blue), then drag on the canvas to draw a rectangle — the selected region is blurred on release; adjust intensity before or after enabling the mode",
|
||||
helpImageEditBlur4: "Rect Mosaic: same as Rect Blur but applies mosaic; preview outline is orange; adjust block size with the slider",
|
||||
helpImageEditBlur5: "Rect Blur and Rect Mosaic are mutually exclusive — enabling one disables the other; click the active toggle again to exit rect mode",
|
||||
helpImageEditBlur6: "All operations support Undo (Ctrl+Z)",
|
||||
helpImageEditBgRemoveTitle: "BG Remove Tool (⬚)",
|
||||
helpImageEditBgRemove1: "Model — Lightweight (@imgly): runs entirely in the browser using the @imgly/background-removal library loaded from CDN; no server required; first use downloads the model (~40 MB) and caches it for the session",
|
||||
helpImageEditBgRemove2: "Model — BiRefNet: high-quality background removal using the BiRefNet model; requires Mask Editor One to be installed and birefnet.safetensors placed in ComfyUI/models/background_removal/",
|
||||
helpImageEditBgRemove3: "New Layer (checked by default): the result is added as a new layer above the active layer at the same position and display size — the original layer is preserved",
|
||||
helpImageEditBgRemove4: "When New Layer is unchecked, the active layer's canvas is replaced in-place with the background-removed result",
|
||||
helpImageEditBgRemove5: "Click Remove BG to start processing; status text shows Loading model → Processing → Done; supports Undo (Ctrl+Z)",
|
||||
helpImageEditGmicTitle: "G'MIC Filter (Edit with G'MIC)",
|
||||
helpImageEditGmic1: "Applies G'MIC-Qt filter effects to the active image layer; the result is loaded back as a new layer",
|
||||
helpImageEditGmic2: "Click \"Edit with G'MIC\" in the toolbar to launch G'MIC-Qt GUI with the current layer image",
|
||||
helpImageEditGmic3: "In G'MIC-Qt: choose a filter, adjust parameters, then click OK — a \"G'MIC-Qt filter output\" window appears; close it to save and return the result to the Image Edit tab",
|
||||
helpImageEditGmic4: "Requires G'MIC-Qt Standalone to be installed and its path configured in Settings → G'MIC-Qt Executable Path; only image-type layers are supported",
|
||||
helpImageEditGmicInstallTitle: "G'MIC-Qt Installation",
|
||||
helpImageEditGmicInstall1: "Download G'MIC-Qt Standalone from gmic.eu/download.html — choose the \"G'MIC-Qt\" package for your OS (Windows: gmic_qt-win64.zip)",
|
||||
helpImageEditGmicInstall2: "Extract the zip to any folder (e.g. C:\\tools\\gmic); no installer required",
|
||||
helpImageEditGmicInstall3: "Open Settings → G'MIC-Qt Executable Path, enter the full path to gmic_qt.exe (e.g. C:\\tools\\gmic\\gmic_qt.exe), then click Save",
|
||||
helpImageEditTabKbdTitle: "Image Edit Tab Shortcuts",
|
||||
helpShortcutsTitle: "Keyboard Shortcuts",
|
||||
helpShortcuts1: "Escape: Close modal/panel",
|
||||
helpShortcuts2: "Enter: Confirm rename, send chat",
|
||||
@@ -737,12 +861,13 @@ const LANGUAGES = {
|
||||
helpShortcuts4: "Double-click: Open detail modal",
|
||||
helpTroubleTitle: "Troubleshooting",
|
||||
helpTrouble1: "Cannot connect to ComfyUI: Check if ComfyUI can generate images, verify URL in Settings, try refreshing or restarting",
|
||||
helpTrouble2: "Cannot connect to AI assistant: Use the connection test button in Settings tab; for Ollama check it is running and models are downloaded; for LM Studio check the server is running and a model is loaded",
|
||||
helpTrouble2: "Cannot connect to AI assistant: Use the connection test button in Settings tab; for Ollama check it is running and models are downloaded; for LM Studio or Lemonade check the server is running and a model is loaded",
|
||||
helpTrouble3: "Workflows not showing: Click refresh button, check if loadable in ComfyUI, try restarting browser/ComfyUI",
|
||||
helpTrouble4: "Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting",
|
||||
helpTrouble5: "LoRA Stack fails with 'No such file or directory' error: The LoRA may not be recognized by comfyui-lora-manager (known issue). Try a different LoRA or check if the file appears in Lora Manager's own UI",
|
||||
helpTrouble6: "Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.",
|
||||
helpTrouble7: "Tagger tab shows no models: verify the model folder exists at ComfyUI/models/tagger/<model-name>/ and contains a .onnx or .h5 file; if onnxruntime is not installed, run: python_embeded\\python.exe -m pip install onnxruntime",
|
||||
helpTrouble8: "Model tab shows a checkpoint you didn't expect (e.g. always the same model regardless of the loaded workflow): the checkpoint saved in the workflow file is not installed, so it was silently replaced with another available model during loading — an error toast reports the original (missing) model name when this happens. Select the correct checkpoint manually in the Model tab, or enable \"Default Checkpoint\" in Settings to force a known-good model on every load.",
|
||||
helpSupportTitle: "Support",
|
||||
helpSupportDesc: "If you find this plugin useful, please consider supporting its development!",
|
||||
helpGithubDesc: "Report issues, request features, and contribute",
|
||||
@@ -766,6 +891,10 @@ const LANGUAGES = {
|
||||
comfyuiConnection: "ComfyUI Connection",
|
||||
comfyuiUrl: "ComfyUI URL",
|
||||
comfyuiUrlHint: "When running as ComfyUI plugin, leave empty to use same origin. Only set if ComfyUI is on a different server.",
|
||||
defaultCheckpointSettings: "Default Checkpoint",
|
||||
defaultCheckpointEnable: "Force default checkpoint on workflow load",
|
||||
defaultCheckpointModel: "Default checkpoint model",
|
||||
defaultCheckpointHint: "When enabled, the Checkpoint of every checkpoint loader node is overridden with this model whenever a workflow is loaded into GenerateUI — useful to avoid errors or garbled images caused by a mismatched model saved in the workflow file.",
|
||||
connectedCheck: "Connected \u2713",
|
||||
failedConnect: "Failed to connect \u2717",
|
||||
ollamaSettings: "AI Assistant Settings (Prompt Tab)",
|
||||
@@ -774,6 +903,8 @@ const LANGUAGES = {
|
||||
selectModel: "Select model...",
|
||||
noModelsFound: "No models found",
|
||||
failedLoadModels: "Failed to load models",
|
||||
modelNotFound: "not found",
|
||||
modelNotFoundHint: "The model saved in this workflow was not found among the installed models. Please select the correct one from the list.",
|
||||
saveOllama: "Save AI Assistant Settings",
|
||||
testConnection: "Test Connection",
|
||||
ollamaConnected: "Connected \u2713",
|
||||
@@ -883,7 +1014,7 @@ const LANGUAGES = {
|
||||
helpMetadata2: "Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow",
|
||||
helpMetadata3: "Right column: LoRA list (with strength values) and Prompt list (click to view full text below)",
|
||||
helpMetadata4: "Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative",
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus.",
|
||||
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus.",
|
||||
},
|
||||
|
||||
ja: {
|
||||
@@ -897,6 +1028,7 @@ const LANGUAGES = {
|
||||
test: "テスト",
|
||||
copy: "コピー",
|
||||
close: "閉じる",
|
||||
clearFilters: "✕ クリア",
|
||||
error: "エラー",
|
||||
|
||||
// -- Tabs --
|
||||
@@ -971,6 +1103,7 @@ const LANGUAGES = {
|
||||
sendToCanvas: "キャンバスへ送る",
|
||||
copyAndSendCanvas: "コピー&キャンバスへ送る",
|
||||
workflowSentToCanvas: "ワークフロー準備完了 — タイトルをキャンバスへドラッグ",
|
||||
workflowSentToCanvasDirect: "ワークフローをキャンバスへ送信しました",
|
||||
comfyUILoadTimeout: "ComfyUIの読み込みがタイムアウトしました",
|
||||
popupBlocked: "ポップアップがブロックされました。このサイトのポップアップを許可してください。",
|
||||
setAsDefault: "デフォルトに設定",
|
||||
@@ -995,6 +1128,7 @@ const LANGUAGES = {
|
||||
deleteConfirm: (name) => `"${name}" を削除しますか?`,
|
||||
summaryGenerated: "要約を生成しました",
|
||||
summarizeError: "要約エラー",
|
||||
summarizeOllamaOnly: "要約機能はOllamaバックエンドのみ対応しています。設定でバックエンドを切り替えてください。",
|
||||
copied: "コピーしました",
|
||||
|
||||
// -- GenerateUI --
|
||||
@@ -1085,9 +1219,15 @@ const LANGUAGES = {
|
||||
unknownWorkflowFormat: "不明なワークフロー形式です",
|
||||
generationComplete: "生成が完了しました",
|
||||
generationError: (msg) => `生成エラー: ${msg}`,
|
||||
defaultCheckpointApplied: (name) => `デフォルトCheckpointを適用しました: ${name}`,
|
||||
checkpointSubstitutedWarning: (names) => `このワークフローのCheckpoint「${names}」が見つからず、無警告で別のモデルに差し替えられました。Modelタブを確認してください。`,
|
||||
rawJsonBypassNote: (list) => `バイパス中(除外・配線は再接続済み): ${list}`,
|
||||
rawJsonMutedNote: (list) => `ミュート中(除外): ${list}`,
|
||||
batchNoneSelected: (what) => `${what}が選択されていません`,
|
||||
batchItemFailed: (i, total, msg) => `[${i}/${total}] 失敗: ${msg}`,
|
||||
batchStopped: (completed, failed) => `バッチ停止 — 完了${completed}件、失敗${failed}件`,
|
||||
batchComplete: (completed, total, failed) => failed > 0 ? `バッチ完了: ${completed}/${total}件 (${failed}件失敗)` : `バッチ完了: ${completed}/${total}件`,
|
||||
batchLoraMissing: (count, total, sample) => `選択したLoRAのうち${count}/${total}件がComfyUIに認識されていません(例: ${sample})。ComfyUIでRefreshするかサーバーを再起動してから再実行してください。`,
|
||||
connectedToComfyUI: "ComfyUIに接続しました",
|
||||
failedToConnect: "接続に失敗しました",
|
||||
modelListsRefreshed: "モデルリストを更新しました",
|
||||
@@ -1233,6 +1373,7 @@ const LANGUAGES = {
|
||||
modelsGenUIUnsupported: "このモデルタイプはGenerateUIに設定できません",
|
||||
modelsGenUINoWorkflow: "GenerateUIにワークフローが読み込まれていません",
|
||||
modelsGenUINoNode: (type) => `現在のワークフローに${type}ノードが見つかりません`,
|
||||
revertPromptTitle: "直前にApplyしたプロンプトに戻す",
|
||||
modelsCopyName: "クリックでモデル名をコピー",
|
||||
modelsCopiedName: "モデル名をコピーしました",
|
||||
modelsFilePath: "ファイルパス",
|
||||
@@ -1347,6 +1488,10 @@ const LANGUAGES = {
|
||||
galleryOutputDirLabel: "Outputフォルダのパス",
|
||||
galleryOutputDirHint: "ギャラリータブで参照するComfyUIのoutputフォルダを設定します。空欄の場合はComfyUIのデフォルトoutputディレクトリを使用します。",
|
||||
gallerySelectImageFirst: "先に画像を選択してください",
|
||||
galleryNoLoadImageNode: "現在のワークフローにLoadImageノードがありません",
|
||||
gallerySentGenUI: "GenerateUI Imageタブへ送信しました",
|
||||
galleryCCNotAvailable: "Send CCはComfyUI Comic Creator内のギャラリーから開いた場合のみ使用できます",
|
||||
gallerySentCC: "ComfyUI Comic Creatorへ送信しました",
|
||||
galleryNoEmbeddedWorkflow: "この画像にはComfyUIワークフローが埋め込まれていません。",
|
||||
galleryBulkSelected: "件選択中",
|
||||
galleryBulkDeselectAll: "選択解除",
|
||||
@@ -1360,6 +1505,15 @@ const LANGUAGES = {
|
||||
galleryBulkMoveTo: "移動先...",
|
||||
galleryBulkExport: "エクスポート",
|
||||
galleryBulkDelete: "ファイル削除",
|
||||
nodesBulkSelected: "件選択中",
|
||||
nodesBulkDeselect: "選択解除",
|
||||
nodesBulkAddToGroup: "グループ選択",
|
||||
nodesBulkGroupAdd: "グループに追加",
|
||||
nodesBulkGroupRemove: "グループから削除",
|
||||
nodesBulkNewGroup: "新しいグループ名",
|
||||
nodesBulkCreateAdd: "作成して追加",
|
||||
nodesBulkFavAll: "★ お気に入り追加(全件)",
|
||||
nodesBulkUnfavAll: "☆ お気に入り解除(全件)",
|
||||
downloadStarted: "ダウンロード開始",
|
||||
exportCompleted: "エクスポート完了。ZIPファイルをダウンロード中です。",
|
||||
|
||||
@@ -1464,6 +1618,20 @@ const LANGUAGES = {
|
||||
aiStatusChatting: "考え中...",
|
||||
aiToastChatFailed: "チャット失敗: ",
|
||||
aiToastWcNoName: "ワイルドカード名を入力してください",
|
||||
aiStatusGeneratingImage: "画像を生成中...",
|
||||
aiChatNoWorkflowLoaded: "GenerateUIにワークフローが読み込まれていません",
|
||||
aiChatGenerateNotReady: "GenerateUIの準備ができていません",
|
||||
aiChatNoImageProduced: "生成は完了しましたが画像が出力されませんでした",
|
||||
aiToastImageGenFailed: "画像生成に失敗しました: ",
|
||||
aiChatSaveSvgToGallery: "Galleryに保存",
|
||||
aiChatSvgSaved: "SVGをGalleryに保存しました",
|
||||
aiChatSvgSaveFailed: "SVGの保存に失敗しました: ",
|
||||
aiChatImgGenToggle: "画像生成を許可する",
|
||||
aiSettingsChatGenTitle: "Chat画像生成",
|
||||
aiSettingsChatGenDedicated: "専用ワークフローを使用",
|
||||
aiSettingsChatI2IGenTitle: "Chat I2I生成",
|
||||
aiChatAttachmentLabel: "画像を添付中",
|
||||
aiChatNoLoadImageNode: "対象ワークフローにI2I用のLoadImageノードが見つかりません",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "ヘルプ",
|
||||
@@ -1480,21 +1648,24 @@ const LANGUAGES = {
|
||||
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、バッジ付与、タグ、メモ、AI要約、キャンバスへ送る、生成UIに読み込み、デフォルト設定、削除",
|
||||
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
|
||||
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
|
||||
helpGen2: "Workflowタブからワークフローを読み込み(App形式 .app.json は非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
|
||||
helpGen2: "Workflowタブからワークフローを読み込み(サブグラフを含むワークフローにも対応 — Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等のテンプレート含む;ComfyUIの簡略化App形式 [.app.json / Linear Mode] のみ非対応)。Reset Workflowボタンは現在のワークフローファイルをディスクから再読込してエディタをリロード。Saveボタン(タブバー右端)はエディタでの編集を含む現在のワークフローを新しい.jsonファイルとしてWorkflowタブに保存(現在のワークフロー名を初期値としたファイル名ダイアログを表示)",
|
||||
helpGen3: "5タブ構成 — Input(プロンプト+画像)、Model、Settings、Feeder、Batch — Input/Model/SettingsタブにはRaw JSON列を常時表示",
|
||||
helpGen4: "Inputタブ:Prompt・Imageの内部タブ切り替え;PromptタブにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA(Single/Stackタブ)/ControlNet/UNET/TextEncoder/Hypernetwork(Strength入力付き)のセレクタ(フィルタ検索付き)",
|
||||
helpGen4: "Inputタブ:Prompt・Imageの内部タブ切り替え;PromptタブにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加);各Applyボタンの左隣の↺ボタンはテキストエリアをノードの現在の適用値に戻す;テキストを選択(または単語・括弧内にカーソルを置くだけでも可)してCtrl+↑/↓を押すと(text:weight)形式で強調weightを±0.05単位で調整(weightが1.0に戻ると括弧を自動解除)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA(Single/Stackタブ)/ControlNet/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)/Hypernetwork(Strength入力付き)のセレクタ(フィルタ検索付き)",
|
||||
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ)",
|
||||
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
|
||||
helpGen9: "画像生成、進捗表示、生成停止、結果表示",
|
||||
helpGen10: "Eagle連携:生成画像をEagleへ自動保存",
|
||||
helpGen10: "Eagle連携:生成画像をEagleへ自動保存(SVG出力にも対応 — 標準SaveSVGNodeおよびcomfyui-tosvgのSave SVG Stringノードの両方)",
|
||||
helpGen11: "Batchタブ — バッチキューのカラムヘッダーのチェックボックスで1種類だけ有効化;Generateで全キュー項目を順次処理;一時停止/再開・停止ボタンで制御可能",
|
||||
helpGen12: "Batchタブ — 左ペインは3タブ構成:Checkpoints(フォルダツリー、Filter/All/None、(root)ヘッダーで全選択)、Sampler(KSamplerのsampler_nameリスト、(root)チェックボックスで全選択)、Scheduler(KSamplerのschedulerリスト、(root)チェックボックスで全選択);各タブにAll/Noneボタンも完備",
|
||||
helpGen13: "Batchタブ — 中央ペイン:グループ選択(Checkpoint | Lora | Prompt | Workflowの内部タブ);Checkpoint/LoraグループはModelsタブ、PromptグループはPromptタブ、WorkflowグループはWorkflowタブの定義を反映 — グループヘッダーにチェックで全メンバーを選択、▶で展開して個別選択も可能",
|
||||
helpGen14: "Batchタブ — 右ペイン(バッチキュー):6列構成 — Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler;各列にチェックボックスがあり1種類のみ有効化可能(ラジオ動作);各列に件数を表示",
|
||||
helpGen15: "Sampler/Schedulerバッチ:左ペインでサンプラーまたはスケジューラーを選択し、バッチキューの対応列を有効化してGenerateを実行;選択した値を順番にKSamplerノードに適用して生成;リストはComfyUIから取得(要接続)",
|
||||
helpGen16: "SPAサイドのワイルドカード展開(A1111スタイル):ImpactWildcard系以外のノード(CLIPTextEncodeなど)のプロンプトに__name__構文が含まれる場合、生成時にSPA側がWFSワイルドカードフォルダの対応ファイルからランダムに1行選んで展開;ネストしたワイルドカードは最大5パスまで再展開;見つからないワイルドカードはそのまま送信;ImpactWildcardEncode/ProcessorノードはスキップしてComfyUIサーバー側(Impact Pack)が処理;単体生成・バッチ生成どちらでも機能",
|
||||
helpGen17: "スタイルバッチ:左ペインのStyleタブでスタイルを選択し、バッチキューのStyle列を有効化してGenerateを実行 — 選択した各スタイルをPositiveおよびNegativeプロンプトに順番に適用;ワークフローコピーに適用されるため読み込み済みワークフローは変更されない;スタイルファイルはページロードのたびに user/default/Workflow-Studio/style/*.json から再読み込み",
|
||||
helpGen18: "Applyボタンを Alt+Click すると Apply & Generate を1操作で実行 — 対象:Positive Prompt・Checkpoint・KSamplerの各Applyボタン;ワークフローに値を書き込んだ後、即座に生成をキューに追加;対象ノードが存在しない場合は何もしない",
|
||||
helpGen19: "Loraバッチ:各LoRA適用時にLora構文(<lora:名前:strength_model:strength_clip>)とCivitAIトリガーワードをPositiveプロンプトへ自動追加 — 毎回元のプロンプトを起点に再構築するため蓄積しない;バッチ完了・停止後はプロンプトを開始前の状態へ自動復元;選択したLoRAがComfyUIのモデル一覧にまだ認識されていない場合は警告トーストを表示(ComfyUIのRefreshまたは再起動が必要)",
|
||||
helpFeederTitle: "GenerateUI Tab — Feeder サブタブ",
|
||||
helpFeederDesc: "左ペイン上部の [Image Loop] / [Gallery] トグルで2つのモードを切り替えられます。",
|
||||
helpFeederImgloopTitle: "Image Loop モード",
|
||||
@@ -1517,7 +1688,7 @@ const LANGUAGES = {
|
||||
helpFeederGal5: "__Feeder__ グループ:自動で作成される予約グループ;名前変更・削除不可;ギャラリータブの各サムネイル上の F ボタンでこのグループに画像を追加",
|
||||
helpFeederGal6: "WFS_GalleryFeeder キャンバスコントロール:このノードには ComfyUI キャンバス上に直接 Run / Stop / After Gen ウィジェットが追加されており、WFM を開かずにギャラリーフィーダーループを実行できます",
|
||||
helpSearchPlaceholder: "ヘルプを検索...",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
|
||||
helpPrompt1: "AIアシスタント(Ollama / LM Studio / Lemonade):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応);バックエンドとURLは設定タブで設定",
|
||||
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
|
||||
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
|
||||
helpPrompt4: "プリセット管理:作成・編集、ポジティブ/ネガティブプロンプトのクリップボードコピー(PP/NP)",
|
||||
@@ -1530,7 +1701,7 @@ const LANGUAGES = {
|
||||
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
|
||||
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
|
||||
helpSettings4: "ComfyUI接続URL設定と接続テスト",
|
||||
helpSettings5: "AI Assistant(プロンプトタブ):バックエンド(Ollama / LM Studio)・API URL・デフォルトモデル・接続テスト",
|
||||
helpSettings5: "AI Assistant(プロンプトタブ):バックエンド(Ollama / LM Studio / Lemonade)・API URL・デフォルトモデル・接続テスト",
|
||||
helpSettings6: "デフォルトワークフローの設定・解除",
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
|
||||
@@ -1545,6 +1716,7 @@ const LANGUAGES = {
|
||||
helpNodes4: "ノードグループの作成・名前変更・削除、ノードの割り当て・解除",
|
||||
helpNodes5: "サイドパネル:詳細(クラス名、パッケージ、カテゴリ、説明、フラグ)、I/O仕様(入出力の型・デフォルト値)、グループ管理",
|
||||
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
|
||||
helpNodes7: "複数選択:Ctrl+クリックで個別トグル、Shift+クリックで範囲選択;一括操作バーでグループへの追加・削除・新規作成して追加・お気に入り一括登録/解除が可能",
|
||||
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embeddingをサブタブで切り替え",
|
||||
helpModels2: "サムネイル / テーブル表示モード切り替え(ページネーション付き)。テーブル表示ではファイル名・サブフォルダ・拡張子に加えCivitAI由来のType / Base Model列を表示。列ヘッダーをクリックでソート — 1回で昇順(▲)、2回で降順(▼)、3回でソート解除。E/D列は有効/無効状態でソート",
|
||||
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ、ステータスフィルタ(すべて / 有効のみ / 無効のみ)",
|
||||
@@ -1572,6 +1744,9 @@ const LANGUAGES = {
|
||||
helpGallery12: "Metadataボタン(緑、詳細パネルのタブバー内):選択画像をMetadataタブで開いて埋め込みデータを詳細確認。画像サムネイルのAlt+クリックでも同じ操作が可能",
|
||||
helpGallery13: "Load GenUIボタン(青、詳細パネルのタブバー内):選択画像に埋め込まれたComfyUIワークフローをGenerateUIタブへ直接読み込んで切り替え。ワークフローが埋め込まれていない場合や非対応形式の場合は警告トーストを表示",
|
||||
helpGallery14: "Taggerボタン(紫、詳細パネルのアクション行):選択画像をTaggerタブのSingleモードで直接開き、即座にタグ付けを開始できる",
|
||||
helpGallery15: "Fボタン(シアン色オーバーレイ、各サムネイル左上):クリック一つで画像を __Feeder__ グループに追加/削除;グループ内の場合は常にシアン色で表示、非グループ時はホバー時のみ表示;FeederタブのGalleryモードへ素早く画像を追加できる",
|
||||
helpGallery16: "Image Editボタン(ツールバー、Refreshボタン横):選択中の画像をImage Editタブに送信;既にレイヤーが存在する場合はキャンバスに合わせてスケールして新規レイヤーとして追加、存在しない場合はLayer 1になる",
|
||||
helpGallery17: "Send GenUI Imageボタン(ツールバー、Image Editボタン横):選択画像をComfyUIにアップロードし、GenerateUIタブの最初のLoadImageノードへ入力として設定;GenerateUI → Input → Imageへ自動切り替え;事前にLoadImageノードを含むワークフローの読み込みが必要",
|
||||
|
||||
// -- Tagger Tab --
|
||||
helpTaggerTitle: "Taggerタブ",
|
||||
@@ -1605,11 +1780,90 @@ const LANGUAGES = {
|
||||
helpSidepanel17: "Send to Canvas:WorkflowタブのSend to CanvasまたはGalleryタブのCopy & Send Canvasをクリックするとワークフローをキャンバスへ直接送信(UI形式・API形式どちらも対応);ComfyUIのツールバーから開いていない場合はUI形式のみタイトルドラッグのフォールバック(タイトルが青くなり緑の●が表示される — ドラッグで読み込み)",
|
||||
helpAiTitle: "AI タブ(A)",
|
||||
helpAi1: "4ペインレイアウト — Translation | Chat | TOOLS | Settings — 全ペインが常時表示される",
|
||||
helpAi2: "翻訳ペイン:Ollama または LM Studio を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成",
|
||||
helpAi5: "設定ペイン:バックエンド(Ollama / LM Studio)・API URL・接続テスト・モデル選択・Free 言語名を設定",
|
||||
helpAi6: "設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、または Lemonade を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット;モデルがtool callingに対応している場合、「猫の画像を作って」のように頼むとGenerateUIに読み込み中のワークフローで生成を実行し、結果をチャット内に表示する;ペインヘッダーの「画像生成を許可する」チェックボックスでこの機能のON/OFFを切り替えられる;画像が添付されている場合(TOOLSペインにドロップした画像)はLLMにもビジョン入力として送信され、generate_image呼び出し時はT2Iの代わりにI2I(image-to-image)の元画像として使われる;tool callingとビジョンの動作を確認済みのモデル: Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される",
|
||||
helpAi5: "設定ペイン:バックエンド(Ollama / LM Studio / Lemonade)・API URL・接続テスト・モデル選択・Free 言語名を設定",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade)の選択、APIのURL設定、接続テスト、モデル選択、Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi7: "Chat ペイン — SVG:LLMはComfyUIワークフローを一切使わず、SVGグラフィックをテキストとして直接生成することもできる;「SVGで〜を描いて」のように頼み、応答にSVGコードが含まれていれば自動でインラインプレビュー表示され、「Galleryに保存」ボタンでGalleryタブの出力フォルダに.svgファイルとして保存できる;tool calling(function calling)を使用しないため、tool calling非対応のモデルでも動作する",
|
||||
helpImageEditTitle: "Image Edit タブ",
|
||||
helpImageEdit1: "レイヤーベースの画像エディタ — 複数のレイヤー(画像・テキスト・描画・マスク)を重ね合わせて画像を合成・エクスポート",
|
||||
helpImageEdit2: "画像の読み込み:キャンバスへのドラッグ&ドロップ、Uploadボタン、またはGalleryタブのImage Editボタンから送信;最初の画像はLayer 1として自動ロック;以降の画像はキャンバスサイズに合わせてスケールされ新規レイヤーとして追加",
|
||||
helpImageEdit3: "Newボタン:空白キャンバスを作成 — W×Hサイズを入力(例: 512x768);既存レイヤーは全て削除し、空のLayer 1を自動追加",
|
||||
helpImageEditToolsTitle: "ツール",
|
||||
helpImageEdit4: "Select (V):クリックでレイヤーを選択、ドラッグで移動、コーナーハンドルでリサイズ、上辺の円ハンドルで回転;テキストレイヤーをダブルクリックすると再編集可能;ツールオプションバーにFlip H / Flip V / 回転角度入力を表示",
|
||||
helpImageEdit5: "Draw (B):アクティブな描画レイヤーにフリーハンドで描画;ツールオプション:カラー、ブラシサイズ、ブレンドモード;プロパティペインにABR画像ブラシ用のMASK EDITOR ONE (COLOR)セクションを常時表示 — ブラシが利用可能な場合は見出しが緑、未インポートの場合はグレー;ブラシが1つも無い間はSelectボタンが無効",
|
||||
helpImageEdit6: "Text (T):キャンバス上をクリックしてテキスト入力オーバーレイを表示;ツールオプションバーでフォントファミリー・サイズ・太字/斜体・配置・カラーを設定;OKをクリックするとテキストの正確なサイズのテキストレイヤーとして配置;Selectツールで既存テキストレイヤーをダブルクリックすると再編集可能",
|
||||
helpImageEdit7: "Shape (S):キャンバスに図形を描画;確定した図形は独立した描画レイヤーになる;ツールオプション:Shapeタイプ(Rect / Ellipse / Line / FreeLine)、Roundedチェック(Rect / Ellipseのみ)、Fillカラー + Noneトグル(Line / FreeLineでは非表示)、Strokeカラー + 幅 + Noneトグル(Line / FreeLineのNone非表示)、Opacityスライダー、↩ Undoボタン;ドラッグで形状プレビュー(青い破線)、離すと確定",
|
||||
helpImageEditLayerTitle: "レイヤーパネル",
|
||||
helpImageEdit8: "レイヤーは前面(上)から背面(下)の順に表示;行をクリックでアクティブ化",
|
||||
helpImageEdit9: "👁 / 🚫 ボタン:レイヤーの表示/非表示を切り替え",
|
||||
helpImageEdit10: "🔒 / 🔓 ボタン:レイヤーロックの切り替え — ロック中は移動・リサイズ・回転不可(オレンジのバウンディングボックスで表示);最初の画像読み込み時にLayer 1が自動ロックされ誤操作を防止",
|
||||
helpImageEdit11: "下部の不透明度スライダーでアクティブレイヤーの透明度を調整",
|
||||
helpImageEdit12: "タイプアイコン:🖼 = 画像レイヤー、T = テキストレイヤー、✏ = 描画レイヤー、⬚ = マスクレイヤー",
|
||||
helpImageEdit13: "Deleteキー:選択レイヤーを削除(2枚以上のレイヤーがある場合のみ)",
|
||||
helpImageEditTextTitle: "テキストの品質",
|
||||
helpImageEdit14: "テキストレイヤーはテキストのバウンディングボックスに合わせて正確なサイズに設定 — 余白なし",
|
||||
helpImageEdit15: "Selectツールでテキストレイヤーをリサイズすると、新しい表示サイズでキャンバスを再生成してテキストの鮮明さを維持(ぼけなし)",
|
||||
helpImageEdit16: "テキストレイヤーの再編集(ダブルクリック)でネイティブサイズにリセット;必要に応じて再スケールを行う",
|
||||
helpImageEditExportTitle: "エクスポートとナビゲーション",
|
||||
helpImageEdit17: "Save PNG:全可視レイヤーを合成してPNGファイルとしてダウンロード",
|
||||
helpImageEdit18: "Save to Gallery:ファイル名を入力(デフォルト: wfs-image-YYYYMMDDHHmmss)してギャラリーのルートフォルダに合成PNGを保存(設定 > Gallery Output Folderで設定);Galleryタブを一度開いてルートフォルダを設定する必要あり",
|
||||
helpImageEdit19: "Send to ComfyUI:合成PNGをComfyUIのinputフォルダに /upload/image でアップロードし、Load Imageノードで選択可能にする",
|
||||
helpImageEdit20: "キャンバスナビゲーション:スクロールホイールでズーム、Space + ドラッグでパン",
|
||||
helpImageEdit21: "Undo / Redo:Ctrl+Z / Ctrl+Y(またはツールバーの ↩ ↪ ボタン)",
|
||||
helpImageEditKbdTitle: "Image Edit キーボードショートカット",
|
||||
helpImageEdit22: "V:Selectツール",
|
||||
helpImageEdit23: "B:Drawツール",
|
||||
helpImageEdit24: "T:Textツール",
|
||||
helpImageEdit25: "S:Shapeツール",
|
||||
helpImageEdit26: "Delete:選択レイヤーを削除(2枚以上のレイヤーがある場合)",
|
||||
helpImageEdit27: "Ctrl+Z / Ctrl+Y:Undo / Redo",
|
||||
helpImageEdit28: "Space + ドラッグ:キャンバスをパン",
|
||||
helpImageEdit29: "マウスホイール:ズームイン / アウト",
|
||||
helpImageEdit30: "ダブルクリック(Selectツールでテキストレイヤー):テキストを再編集",
|
||||
helpImageEdit7b: "Blur (≈):アクティブレイヤーにガウスぼかしまたはモザイクを適用 — 全体またはドラッグで矩形範囲指定;詳細は下の「Blur Tool」カードを参照",
|
||||
helpImageEdit7c: "BG Remove (⬚):Lightweight(@imgly、ブラウザ内処理)またはBiRefNet(Mask Editor One + ComfyUI/models/background_removal/にbirefnet.safetensors必須)でアクティブレイヤーの背景を除去;詳細は下の「BG Remove Tool」カードを参照",
|
||||
helpImageEdit7d: "Mask (🎭):6つのサブツールでマスクレイヤーにマスクを描画 — Paint(フリーハンド)・Color(色類似度フラッドセレクト)・Alpha(画像の透過チャンネル抽出)・Text(テキストスタンプ)・Vector(Catmull-Romスプライン多角形)・Shape(矩形/楕円);Mask Editor OneインストールでSAM3テキストプロンプトセグメンテーションも利用可能;レイヤーヘッダーの「M」でマスクレイヤーを追加;詳細は下の「Mask Tool」カードを参照",
|
||||
helpImageEdit7e: "Inpaint (🩹):表示中のレイヤーとマスクレイヤーを1枚のRGBA画像に合成(マスクをアルファチャンネルに焼き込み)し、GenerateUIで読み込み中のワークフロー内でインペイント対応の最初のLoadImageノード(MASK出力がVAEEncodeForInpaint等に接続されているノード)へ適用;プロパティパネルでポジ/ネガプロンプト、Grow Mask By(VAE Encode (for Inpainting)ノードへ反映)、Denoise(KSamplerへ反映)を入力してRunを押すと、Image Editタブに留まったままアップロード・生成を実行;完了後は結果画像がプロパティパネルに表示される;事前にマスクレイヤーの描画とインペイント対応ワークフローの読み込みが必要;LoadImageの代わりにMask Editor Oneカスタムノード(comfyui-mask-editor-one)を使ったワークフローにも対応(node_idキーのサーバーキャッシュとlayer_dataウィジェット経由で画像/マスクを反映);「Use dedicated workflow」チェックボックス:OFF時はGenerateUIで読み込み中のワークフローを使用(従来通り)、ON時はドロップダウンから保存済みワークフローを選択すると、GenerateUIの表示状態には触れずバックグラウンドで読み込み・編集・生成を実行",
|
||||
helpImageEdit8b: "レイヤーヘッダーの「M」ボタン:アクティブレイヤーの上に新しいマスクレイヤーを追加し、自動的にMaskツールに切り替える",
|
||||
helpImageEditMaskTitle: "Mask ツール (🎭)",
|
||||
helpImageEditMask1: "マスクレイヤーの追加:レイヤーパネルヘッダーの「M」ボタンをクリック;新しいマスクレイヤーはアクティブレイヤーの上に挿入され、Maskツールが自動的に有効化される",
|
||||
helpImageEditMask2: "レイヤーリストでマスクレイヤーをクリックすると自動的にMaskツールに切り替わる",
|
||||
helpImageEditMask3: "ツールオプションバー — サブツール:Paint / Color / Alpha / Text / Vector / Shape(全てMask Editor One不要)/ SAM3(Mask Editor One必須・未インストール時は無効);Invertチェックボックスでオーバーレイプレビューを反転;Overlayカラーピッカーでプレビューカラーを変更(デフォルト赤);Blurスライダー(0〜50 px)でオーバーレイ輪郭をぼかす;オーバーレイは選択中(アクティブ)のマスクレイヤーのみ表示され、非アクティブなマスクは選択するまで非表示",
|
||||
helpImageEditMask4: "プロパティペイン — Paintモード:Mode(Add / Erase)・Size(1〜200 px)・Hardness(0〜100%);MASK EDITOR ONEセクション(常時表示 — ABRブラシが利用可能な場合は見出しが緑、未インポートの場合はグレー):Selectボタンでブラシピッカーを開く(ブラシが1つも無い間は無効)(選択したブラシがデフォルト円形に代わる);✕でCircle(デフォルト)に戻す;画像ブラシ選択中はHardnessが無効になり追加コントロールが表示:Spacing(スタンプ間隔 5〜100%)・Angle(回転角 0〜359°)・Sz Jitter(スタンプごとのサイズランダム変動 0〜100%)・Rot. Jitter(スタンプごとの角度ランダム変動 on/off)",
|
||||
helpImageEditMask5: "マスクレイヤー行の ✂ ボタン:クリッピングマスクとして機能させるon/off切り替え — 有効(青)のとき、直下のレイヤーをマスクの描画範囲でクリッピングして表示;クリッピングはPNG保存 / Gallery / ComfyUI送信にも反映される",
|
||||
helpImageEditMask6: "SAM3(Mask Editor One必須):SAM3モデルによるテキストプロンプトセグメンテーション;対象オブジェクトの説明(例:\"cat\"・\"person\")を入力し、Max(マスク候補の最大数:3/6/9/12)を設定してSegmentをクリック",
|
||||
helpImageEditMask7: "SAM3の操作フロー:Segment後、候補マスクのサムネイルがプロパティペインに表示される — クリックして選択(青ボーダー + ✓);複数選択可;Apply Selected(N)で選択したマスクを一括適用;Mode(Add / Erase)で現在のマスクレイヤーへの合成方法を制御;アクティブレイヤーがマスクでない場合は新規マスクレイヤーを自動作成",
|
||||
helpImageEditMask8: "ABRブラシライブラリ(Mask Editor One必須・Paintモードのみ):MASK EDITOR ONEセクションのSelectボタンでブラシピッカーを開く — 左のフォルダツリーで絞り込み、サムネイルをクリックして適用;「Round Brush (default)」で円形ブラシに戻す;ブラシはMask Editor Oneと共有しその brushes/ フォルダに保存される",
|
||||
helpImageEditMask9: "Color サブツール:キャンバスをクリックするとクリック位置の色に近いピクセルをマスクとして選択 — プロパティペイン:Mode(Add / Subtract)・Tolerance(0〜255、色の許容範囲)・Feather %(0〜100、許容範囲内でのぼかし量)",
|
||||
helpImageEditMask10: "Alpha サブツール:画像レイヤーの透過チャンネルをマスクとして抽出 — プロパティペイン:Threshold(この値以上のアルファを持つピクセルが完全マスクになる;0 = 全アルファを含む)・Invert(抽出結果を反転)・「Extract Alpha」ボタンで実行;表示中の全非マスクレイヤーの合成から抽出",
|
||||
helpImageEditMask11: "Text サブツール:キャンバスをクリックするとテキストスタンプオーバーレイを表示;テキストを入力し Font / Size / Bold / Italic / Align と Mode(Add = 白スタンプ / Erase = マスク削除)を設定して「Stamp」またはCtrl+Enterで確定;テキストエリア内の改行で複数行対応",
|
||||
helpImageEditMask12: "Vector サブツール:クリックでCatmull-Romスプラインの制御点を追加;最初の点を再クリック(またはEnter)で閉じてポリゴンを塗りつぶし;Backspaceで直前の点を削除;Escで全点をリセット;プロパティペインに現在の点数とMode(Add / Erase)を表示",
|
||||
helpImageEditMask13: "Shape サブツール:ドラッグで矩形または楕円のマスクを描画;Shift押下で正方形/正円;ドラッグ中は青の点線でプレビュー表示;プロパティペイン:Mode(Add / Erase)・Shape(Rect / Ellipse)",
|
||||
helpImageEditBlurTitle: "Blur ツール (≈)",
|
||||
helpImageEditBlur1: "Whole Blur:アクティブレイヤー全体にガウスぼかしを適用 — 強度スライダー(1〜50 px)で調整してApplyをクリック",
|
||||
helpImageEditBlur2: "Whole Mosaic:アクティブレイヤー全体にピクセル化モザイクを適用 — ブロックサイズスライダー(5〜100 px)で調整してApplyをクリック",
|
||||
helpImageEditBlur3: "Rect Blur:トグルをクリックして矩形モードに入る(ボタンが青く点灯)、キャンバス上でドラッグして矩形範囲を選択 — リリース時に選択範囲がぼかされる;モード有効化前後どちらでも強度を調整できる",
|
||||
helpImageEditBlur4: "Rect Mosaic:Rect Blurと同様だがモザイクを適用;プレビューのアウトラインは橙色;スライダーでブロックサイズを調整",
|
||||
helpImageEditBlur5: "Rect BlurとRect Mosaicは排他的 — 一方を有効にするともう一方は無効になる;アクティブなトグルを再クリックすると矩形モードを終了",
|
||||
helpImageEditBlur6: "全操作でUndo(Ctrl+Z)に対応",
|
||||
helpImageEditBgRemoveTitle: "BG Remove ツール (⬚)",
|
||||
helpImageEditBgRemove1: "モデル — Lightweight(@imgly):CDNから読み込む @imgly/background-removal ライブラリを使用してブラウザ内で処理;サーバー不要;初回使用時にモデル(約40 MB)をダウンロードしてセッション中にキャッシュ",
|
||||
helpImageEditBgRemove2: "モデル — BiRefNet:BiRefNetモデルによる高品質な背景除去;Mask Editor Oneのインストールと ComfyUI/models/background_removal/ への birefnet.safetensors 配置が必要",
|
||||
helpImageEditBgRemove3: "New Layer(デフォルトON):結果をアクティブレイヤーの上に同じ位置・表示サイズの新規レイヤーとして追加 — 元レイヤーは保持",
|
||||
helpImageEditBgRemove4: "New LayerをOFFにすると、アクティブレイヤーのキャンバスが背景除去済みの結果で直接置き換えられる",
|
||||
helpImageEditBgRemove5: "Remove BGをクリックして処理開始;ステータステキストに「Loading model → Processing → Done」と表示;Undo(Ctrl+Z)に対応",
|
||||
helpImageEditGmicTitle: "G'MIC フィルター(G'MICで編集)",
|
||||
helpImageEditGmic1: "アクティブな画像レイヤーにG'MIC-Qtのフィルター効果を適用する;処理結果は新規レイヤーとして読み込まれる",
|
||||
helpImageEditGmic2: "ツールバーの「G'MICで編集」ボタンをクリックすると、現在のレイヤー画像でG'MIC-Qt GUIを起動する",
|
||||
helpImageEditGmic3: "G'MIC-Qt上でフィルターを選択してパラメーターを調整後にOKをクリック —「G'MIC-Qt filter output」ウィンドウが表示されるので閉じると結果がImage Editタブに反映される",
|
||||
helpImageEditGmic4: "G'MIC-Qt Standaloneがインストールされ、設定タブの「G'MIC-Qt Executable Path」にパスが設定されている必要がある;画像タイプのレイヤーのみ対応",
|
||||
helpImageEditGmicInstallTitle: "G'MIC-Qt のインストール",
|
||||
helpImageEditGmicInstall1: "gmic.eu/download.html から G'MIC-Qt Standalone をダウンロード — OS に合ったパッケージを選択(Windows: gmic_qt-win64.zip)",
|
||||
helpImageEditGmicInstall2: "zipを任意のフォルダに解凍する(例: C:\\tools\\gmic);インストーラー不要",
|
||||
helpImageEditGmicInstall3: "設定タブ → G'MIC-Qt Executable Path に gmic_qt.exe のフルパスを入力(例: C:\\tools\\gmic\\gmic_qt.exe)して Save をクリック",
|
||||
helpImageEditTabKbdTitle: "Image Edit タブのショートカット",
|
||||
helpShortcutsTitle: "キーボード操作",
|
||||
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
|
||||
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
|
||||
@@ -1617,12 +1871,13 @@ const LANGUAGES = {
|
||||
helpShortcuts4: "ダブルクリック:詳細モーダルを開く",
|
||||
helpTroubleTitle: "トラブルシューティング",
|
||||
helpTrouble1: "ComfyUIに接続できない:ComfyUIで画像生成できるか確認、設定タブでURL確認、ブラウザ更新・再起動",
|
||||
helpTrouble2: "AIアシスタントに接続できない:設定タブの接続テストボタンで確認;Ollamaの場合は起動とモデルのダウンロードを確認;LM Studioの場合はサーバー起動とモデルのロードを確認",
|
||||
helpTrouble2: "AIアシスタントに接続できない:設定タブの接続テストボタンで確認;Ollamaの場合は起動とモデルのダウンロードを確認;LM StudioまたはLemonadeの場合はサーバー起動とモデルのロードを確認",
|
||||
helpTrouble3: "ワークフローが表示されない:更新ボタン押下、ComfyUIで読み込めるか確認、ブラウザ・ComfyUI再起動",
|
||||
helpTrouble4: "画像生成が失敗する:指定モデルの存在確認、ComfyUI接続確認、再起動",
|
||||
helpTrouble5: "LoRAスタック実行時に「No such file or directory」エラー:comfyui-lora-managerが該当LoRAを認識していない可能性があります(既知の問題)。別のLoRAを試すか、Lora Manager自体のUIで該当ファイルが表示されるか確認してください",
|
||||
helpTrouble6: "モデルの移動・削除後にモデル読み込みエラーが発生する:ComfyUIがファイルパスをキャッシュしており、古い場所を参照することがあります。ComfyUIメニューのHard Reset(Queue → Hard Reset)またはComfyUIを再起動してキャッシュをクリアしてください。",
|
||||
helpTrouble7: "Taggerタブにモデルが表示されない:ComfyUI/models/tagger/<モデル名>/ フォルダの存在と .onnx または .h5 ファイルの有無を確認;onnxruntimeが未インストールの場合は python_embeded\\python.exe -m pip install onnxruntime を実行",
|
||||
helpTrouble8: "ModelタブのCheckpointが想定と違うモデルになっている(どのワークフローを読み込んでも同じモデルになる等):ワークフローファイルに保存されたCheckpointが実際にはインストールされておらず、読み込み時に無警告で別のインストール済みモデルに差し替えられています。この場合、元の(見つからなかった)モデル名を含むエラートーストが表示されます。Modelタブで正しいCheckpointを手動選択するか、設定タブの「デフォルトCheckpoint」を有効にして、読み込み時に常に動作確認済みのモデルを強制適用してください。",
|
||||
helpSupportTitle: "サポート",
|
||||
helpSupportDesc: "このプラグインが役に立ったら、開発を支援していただけると嬉しいです!",
|
||||
helpGithubDesc: "バグ報告、機能リクエスト、コントリビュート",
|
||||
@@ -1646,6 +1901,10 @@ const LANGUAGES = {
|
||||
comfyuiConnection: "ComfyUI 接続",
|
||||
comfyuiUrl: "ComfyUI URL",
|
||||
comfyuiUrlHint: "ComfyUIプラグインとして動作する場合は空欄(同一オリジン推奨)。別サーバーの場合のみ設定してください。",
|
||||
defaultCheckpointSettings: "デフォルトCheckpoint",
|
||||
defaultCheckpointEnable: "ワークフロー読み込み時にデフォルトCheckpointを強制適用する",
|
||||
defaultCheckpointModel: "デフォルトCheckpointモデル",
|
||||
defaultCheckpointHint: "有効にすると、GenerateUIにワークフローを読み込むたびに、すべてのCheckpointローダーノードのモデルがこの値で上書きされます。ワークフローファイルに保存されたモデルが環境と合わず、生成エラーや不鮮明な画像が発生するのを防ぐための設定です。",
|
||||
connectedCheck: "接続成功 \u2713",
|
||||
failedConnect: "接続失敗 \u2717",
|
||||
ollamaSettings: "AI Assistant設定(プロンプトタブ)",
|
||||
@@ -1654,6 +1913,8 @@ const LANGUAGES = {
|
||||
selectModel: "モデルを選択...",
|
||||
noModelsFound: "モデルが見つかりません",
|
||||
failedLoadModels: "モデル一覧の取得に失敗",
|
||||
modelNotFound: "見つかりません",
|
||||
modelNotFoundHint: "このワークフローに保存されているモデルが、インストール済みモデルの中に見つかりませんでした。正しいモデルを一覧から選択してください。",
|
||||
saveOllama: "AI Assistant設定を保存",
|
||||
testConnection: "接続テスト",
|
||||
ollamaConnected: "接続成功 \u2713",
|
||||
@@ -1763,7 +2024,7 @@ const LANGUAGES = {
|
||||
helpMetadata2: "中列: ワークフローから抽出した Checkpoint・VAE・Diffusion Model・Text Encoder の一覧",
|
||||
helpMetadata3: "右列: LoRA 一覧(強度値付き)+プロンプト一覧(クリックで下部に全文表示)",
|
||||
helpMetadata4: "プロンプトアクション — Copy: クリップボードにコピー; GenUI:P/N: GenerateUI のポジティブ/ネガティブプロンプトにセット; Prompt:P/N: Prompt タブ Presets のポジティブ/ネガティブにセット",
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
|
||||
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -1777,6 +2038,7 @@ const LANGUAGES = {
|
||||
test: "测试",
|
||||
copy: "复制",
|
||||
close: "关闭",
|
||||
clearFilters: "✕ 清除",
|
||||
error: "错误",
|
||||
|
||||
// -- Tabs --
|
||||
@@ -1851,6 +2113,7 @@ const LANGUAGES = {
|
||||
sendToCanvas: "发送到画布",
|
||||
copyAndSendCanvas: "复制并发送到画布",
|
||||
workflowSentToCanvas: "工作流已就绪 — 将标题拖拽到画布",
|
||||
workflowSentToCanvasDirect: "工作流已发送到画布",
|
||||
comfyUILoadTimeout: "ComfyUI加载超时",
|
||||
popupBlocked: "弹窗被阻止。请允许此站点的弹窗。",
|
||||
setAsDefault: "设为默认",
|
||||
@@ -1875,6 +2138,7 @@ const LANGUAGES = {
|
||||
deleteConfirm: (name) => `确定删除 "${name}" 吗?`,
|
||||
summaryGenerated: "摘要已生成",
|
||||
summarizeError: "摘要错误",
|
||||
summarizeOllamaOnly: "摘要功能仅支持 Ollama 后端,请在设置中切换后端。",
|
||||
copied: "已复制",
|
||||
|
||||
// -- GenerateUI --
|
||||
@@ -1965,9 +2229,15 @@ const LANGUAGES = {
|
||||
unknownWorkflowFormat: "未知的工作流格式",
|
||||
generationComplete: "生成完成",
|
||||
generationError: (msg) => `生成错误: ${msg}`,
|
||||
defaultCheckpointApplied: (name) => `已应用默认 Checkpoint:${name}`,
|
||||
checkpointSubstitutedWarning: (names) => `此工作流中的 Checkpoint「${names}」未找到,已被静默替换为其他模型。请检查 Model 标签页。`,
|
||||
rawJsonBypassNote: (list) => `已绕过(排除,已重新连接布线): ${list}`,
|
||||
rawJsonMutedNote: (list) => `已静音(排除): ${list}`,
|
||||
batchNoneSelected: (what) => `未选择${what}`,
|
||||
batchItemFailed: (i, total, msg) => `[${i}/${total}] 失败: ${msg}`,
|
||||
batchStopped: (completed, failed) => `批处理已停止 — 完成${completed}个,失败${failed}个`,
|
||||
batchComplete: (completed, total, failed) => failed > 0 ? `批处理完成: ${completed}/${total}个 (${failed}个失败)` : `批处理完成: ${completed}/${total}个`,
|
||||
batchLoraMissing: (count, total, sample) => `所选LoRA中有${count}/${total}个尚未被ComfyUI识别(例如:${sample})。请在ComfyUI中点击Refresh或重启服务器后再试。`,
|
||||
connectedToComfyUI: "已连接到ComfyUI",
|
||||
failedToConnect: "连接失败",
|
||||
modelListsRefreshed: "模型列表已刷新",
|
||||
@@ -2113,6 +2383,7 @@ const LANGUAGES = {
|
||||
modelsGenUIUnsupported: "此模型类型无法在GenerateUI中设置",
|
||||
modelsGenUINoWorkflow: "GenerateUI中未加载工作流",
|
||||
modelsGenUINoNode: (type) => `当前工作流中未找到${type}节点`,
|
||||
revertPromptTitle: "恢复到上次应用的提示词",
|
||||
modelsCopyName: "点击复制模型名称",
|
||||
modelsCopiedName: "模型名称已复制",
|
||||
modelsFilePath: "文件路径",
|
||||
@@ -2227,6 +2498,10 @@ const LANGUAGES = {
|
||||
galleryOutputDirLabel: "输出文件夹路径",
|
||||
galleryOutputDirHint: "设置图库标签页浏览的ComfyUI输出文件夹。留空则使用ComfyUI默认输出目录。",
|
||||
gallerySelectImageFirst: "请先选择图像",
|
||||
galleryNoLoadImageNode: "当前工作流中没有LoadImage节点",
|
||||
gallerySentGenUI: "已发送至GenerateUI图像标签",
|
||||
galleryCCNotAvailable: "仅当从ComfyUI Comic Creator内的图库打开时才能使用Send CC",
|
||||
gallerySentCC: "已发送至ComfyUI Comic Creator",
|
||||
galleryNoEmbeddedWorkflow: "此图像中未嵌入ComfyUI工作流。",
|
||||
galleryBulkSelected: "项已选择",
|
||||
galleryBulkDeselectAll: "取消全选",
|
||||
@@ -2240,6 +2515,15 @@ const LANGUAGES = {
|
||||
galleryBulkMoveTo: "移动到...",
|
||||
galleryBulkExport: "导出",
|
||||
galleryBulkDelete: "删除文件",
|
||||
nodesBulkSelected: "项已选择",
|
||||
nodesBulkDeselect: "取消全选",
|
||||
nodesBulkAddToGroup: "选择分组",
|
||||
nodesBulkGroupAdd: "添加到分组",
|
||||
nodesBulkGroupRemove: "从分组中删除",
|
||||
nodesBulkNewGroup: "新建分组名称",
|
||||
nodesBulkCreateAdd: "创建并添加",
|
||||
nodesBulkFavAll: "★ 全部收藏",
|
||||
nodesBulkUnfavAll: "☆ 全部取消收藏",
|
||||
downloadStarted: "下载开始",
|
||||
exportCompleted: "导出完成。正在下载 ZIP 文件。",
|
||||
|
||||
@@ -2344,6 +2628,20 @@ const LANGUAGES = {
|
||||
aiStatusChatting: "思考中...",
|
||||
aiToastChatFailed: "聊天失败:",
|
||||
aiToastWcNoName: "请输入通配符名称",
|
||||
aiStatusGeneratingImage: "正在生成图像...",
|
||||
aiChatNoWorkflowLoaded: "GenerateUI 中未加载工作流",
|
||||
aiChatGenerateNotReady: "GenerateUI 尚未就绪",
|
||||
aiChatNoImageProduced: "生成完成但未产生图像",
|
||||
aiToastImageGenFailed: "图像生成失败:",
|
||||
aiChatSaveSvgToGallery: "保存到图库",
|
||||
aiChatSvgSaved: "SVG已保存到图库",
|
||||
aiChatSvgSaveFailed: "SVG保存失败:",
|
||||
aiChatImgGenToggle: "允许生成图像",
|
||||
aiSettingsChatGenTitle: "Chat 图像生成",
|
||||
aiSettingsChatGenDedicated: "使用专用工作流",
|
||||
aiSettingsChatI2IGenTitle: "Chat I2I 生成",
|
||||
aiChatAttachmentLabel: "已附加图像",
|
||||
aiChatNoLoadImageNode: "目标工作流中未找到用于图生图的 LoadImage 节点",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "帮助",
|
||||
@@ -2360,21 +2658,24 @@ const LANGUAGES = {
|
||||
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、徽章分配、标签、备注、AI摘要、发送到画布、加载到生成UI、设为默认、删除",
|
||||
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
|
||||
helpGen1: "连接/断开ComfyUI,检查连接状态",
|
||||
helpGen2: "从工作流标签页加载工作流(不支持App格式.app.json)。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
|
||||
helpGen2: "从工作流标签页加载工作流(支持含子图的工作流,例如 Flux.2 / Qwen-Image / Z-Image / Mage-Flow 等模板;仅不支持ComfyUI的简化App格式 [.app.json / Linear Mode])。Reset Workflow按钮从磁盘重新读取当前工作流文件并重新加载编辑器。Save按钮(标签栏右端)将当前工作流(包括在编辑器中所做的编辑)保存为新的.json文件到工作流标签页;会显示以当前工作流名称为默认值的文件名对话框",
|
||||
helpGen3: "5标签布局 — Input(提示词+图像)、Model、Settings、Feeder、Batch — Input/Model/Settings各含实时预览和直接编辑的Raw JSON列",
|
||||
helpGen4: "Input标签:含Prompt和Image子标签(拖放上传);Prompt标签显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/LoRA(Single/Stack标签)/ControlNet/UNET/TextEncoder/Hypernetwork(带Strength输入)选择器(含筛选)",
|
||||
helpGen4: "Input标签:含Prompt和Image子标签(拖放上传);Prompt标签显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive);每个Apply按钮左侧的↺按钮可将文本框恢复为节点当前已应用的值;选中文字(或将光标置于单词/括号内也可)后按Ctrl+↑/↓,即可以(text:weight)语法以±0.05为单位调整强调权重(权重恢复为1.0时自动去除括号)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/LoRA(Single/Stack标签)/ControlNet/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)/Hypernetwork(带Strength输入)选择器(含筛选)",
|
||||
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致)",
|
||||
helpGen8: "种子管理:随机/固定/递增/递减",
|
||||
helpGen9: "生成图片、进度显示、停止生成、查看结果",
|
||||
helpGen10: "Eagle集成:自动保存生成图片到Eagle",
|
||||
helpGen10: "Eagle集成:自动保存生成图片到Eagle(也支持SVG输出 — 标准SaveSVGNode节点和comfyui-tosvg的Save SVG String节点均可)",
|
||||
helpGen11: "Batch标签 — 勾选批次队列列标题复选框激活对应批次类型(每次仅一种);Generate按顺序处理所有队列项目;支持暂停/继续/停止控制",
|
||||
helpGen12: "Batch标签 — 左窗格含3个标签:Checkpoints(文件树,Filter/All/None,(root)标题勾选全选)、Sampler(KSampler采样器列表,(root)复选框全选)、Scheduler(KSampler调度器列表,(root)复选框全选);每个标签均有All/None按钮",
|
||||
helpGen13: "Batch标签 — 中央窗格:基于组的选择(含Checkpoint | Lora | Prompt | Workflow内部标签);Checkpoint/Lora组来自Models标签,Prompt组来自Prompt标签,Workflow组来自Workflow标签 — 勾选组标题选中所有成员,▶展开后也可单独选择",
|
||||
helpGen14: "Batch标签 — 右窗格(批量队列):6列 — Checkpoint / Lora / Prompt / Workflow / Sampler / Scheduler;每列有启用复选框(单选行为,每次仅一种);每列显示数量",
|
||||
helpGen15: "Sampler/Scheduler批次:在左窗格选择采样器或调度器,启用批次队列对应列后点击Generate;依次将每个值应用到KSampler节点逐一生成;列表从ComfyUI获取(需已连接)",
|
||||
helpGen16: "SPA端通配符展开(A1111风格):当非ImpactWildcard系节点(如CLIPTextEncode)的提示词中含有__name__语法时,SPA在生成时从WFS通配符文件夹的对应文件中随机选取一行展开;嵌套通配符最多重复展开5次;未找到的通配符原样传递;ImpactWildcardEncode/Processor节点跳过(由ComfyUI服务端Impact Pack处理);单次生成和批量生成均有效",
|
||||
helpGen17: "样式批处理:在左窗格Style标签中选择样式,启用批次队列Style列后点击Generate — 每个选中样式依次应用到正向和负向提示词;样式应用于工作流副本,不会修改已加载的工作流;每次页面加载时从 user/default/Workflow-Studio/style/*.json 重新读取样式文件",
|
||||
helpGen18: "Alt+单击Apply按钮(Positive Prompt / Checkpoint / KSampler)可一步完成Apply & Generate — 将参数写入工作流后立即排队生成;仅当工作流中存在目标节点时才触发生成",
|
||||
helpGen19: "Lora批处理:应用每个LoRA时,会自动将其语法(<lora:名称:strength_model:strength_clip>)和CivitAI触发词追加到Positive提示词 — 每次都基于原始提示词重新构建,不会累积;批处理完成或停止后提示词会自动恢复到批处理前的状态;若所选LoRA尚未被ComfyUI模型列表识别,会显示警告提示(需在ComfyUI中Refresh或重启)",
|
||||
helpFeederTitle: "GenerateUI Tab — Feeder 子标签",
|
||||
helpFeederDesc: "通过左侧面板顶部的 [图片循环] / [图库] 切换按钮可在两种模式间切换。",
|
||||
helpFeederImgloopTitle: "图片循环模式",
|
||||
@@ -2397,7 +2698,7 @@ const LANGUAGES = {
|
||||
helpFeederGal5: "__Feeder__ 分组:自动预先创建的保留分组;不可重命名或删除;在图库标签页中点击每个缩略图上的 F 按钮可将图片添加到此分组",
|
||||
helpFeederGal6: "WFS_GalleryFeeder 画布控件:该节点在 ComfyUI 画布上直接添加了 Run / Stop / After Gen 组件——与 WFM 界面中的控件功能相同,无需打开 WFM 即可运行图库供给循环",
|
||||
helpSearchPlaceholder: "搜索帮助...",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
|
||||
helpPrompt1: "AI助手(Ollama / LM Studio / Lemonade):模型选择、聊天咨询提示词、图片附件(视觉模型);后端和URL在设置标签页配置",
|
||||
helpPrompt2: "将AI回复应用到生成UI的提示词",
|
||||
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
|
||||
helpPrompt4: "预设管理:创建、编辑、正面/负面提示词剪贴板复制(PP/NP)",
|
||||
@@ -2410,7 +2711,7 @@ const LANGUAGES = {
|
||||
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
|
||||
helpSettings3: "工作流数据文件夹路径更改/重置",
|
||||
helpSettings4: "ComfyUI连接URL设置和测试",
|
||||
helpSettings5: "AI助手(提示词标签页):后端(Ollama / LM Studio)、API URL、默认模型、连接测试",
|
||||
helpSettings5: "AI助手(提示词标签页):后端(Ollama / LM Studio / Lemonade)、API URL、默认模型、连接测试",
|
||||
helpSettings6: "默认工作流设置/取消",
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
|
||||
@@ -2425,6 +2726,7 @@ const LANGUAGES = {
|
||||
helpNodes4: "创建、重命名、删除节点组,分配/移除节点",
|
||||
helpNodes5: "侧面板:详情(类名、包、分类、描述、标志)、I/O规格(输入输出类型和默认值)、组管理",
|
||||
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
|
||||
helpNodes7: "多选:Ctrl+点击切换单个节点,Shift+点击范围选择;出现批量操作栏,可添加到组/从组移除/新建组并添加/批量收藏/取消收藏",
|
||||
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embedding,通过子标签切换",
|
||||
helpModels2: "缩略图和表格视图模式切换(带分页)。表格视图除文件名、子文件夹和扩展名外,还显示来自CivitAI数据的Type / Base Model列。点击列标题可排序 — 第一次升序(▲),第二次降序(▼),第三次取消排序。E/D列按启用/禁用状态排序",
|
||||
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选、状态筛选(全部 / 仅启用 / 仅禁用)",
|
||||
@@ -2452,6 +2754,9 @@ const LANGUAGES = {
|
||||
helpGallery12: "Metadata按钮(绿色,位于详情面板标签栏):在Metadata标签中打开所选图像以全面查看嵌入数据;Alt+点击任意图像缩略图效果相同",
|
||||
helpGallery13: "Load GenUI按钮(蓝色,位于详情面板标签栏):将所选图像中嵌入的ComfyUI工作流直接加载到GenerateUI标签并切换过去;如果没有嵌入工作流或格式不受支持,则显示警告提示",
|
||||
helpGallery14: "Tagger按钮(紫色,位于详情面板操作行):将所选图像直接在Tagger标签的Single模式中打开,即可立即进行标注",
|
||||
helpGallery15: "F按钮(青色悬浮按钮,每个缩略图左上角):单击即可将图像添加到/从__Feeder__组中移除;图像在组中时始终显示青色,不在组中时仅悬停时可见;可快速为Feeder标签Gallery模式添加队列图像",
|
||||
helpGallery16: "Image Edit按钮(工具栏,Refresh按钮旁):将当前选中的图像发送到Image Edit标签;如果已有图层则按比例缩放添加为新图层,否则将成为Layer 1",
|
||||
helpGallery17: "Send GenUI Image按钮(工具栏,Image Edit按钮旁):将选中图像上传至ComfyUI并设置为GenerateUI标签中第一个LoadImage节点的输入;自动切换至GenerateUI → Input → Image;需要预先加载包含至少一个LoadImage节点的工作流",
|
||||
|
||||
// -- Tagger Tab --
|
||||
helpTaggerTitle: "Tagger标签",
|
||||
@@ -2485,11 +2790,90 @@ const LANGUAGES = {
|
||||
helpSidepanel17: "Send to Canvas:在Workflow标签点击'Send to Canvas'或Gallery标签点击'Copy & Send Canvas',工作流直接发送到ComfyUI画布(支持UI和API所有格式);若Workflow Studio不是从ComfyUI工具栏打开,UI格式工作流将回退到标题拖拽(标题变蓝并显示绿色●——拖拽到画布加载)",
|
||||
helpAiTitle: "AI 标签页(A)",
|
||||
helpAi1: "4 面板布局 — Translation | Chat | TOOLS | Settings — 所有面板始终可见",
|
||||
helpAi2: "翻译面板:使用 Ollama 或 LM Studio 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条)",
|
||||
helpAi5: "设置面板:选择后端(Ollama / LM Studio)、设置 API URL、测试连接、选择模型、配置 Free 语言名称",
|
||||
helpAi6: "设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio 或 Lemonade 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史;如果模型支持 tool calling,当你要求生成图像(例如\"生成一张猫的图片\")时,会使用 GenerateUI 中当前加载的工作流执行生成,并在对话中显示结果;面板标题栏的\"允许生成图像\"复选框可开关此功能;如果附加了图像(在 TOOLS 面板中拖放的图像),该图像会作为视觉输入发送给 LLM,调用 generate_image 时会以该图像为基础执行图生图(I2I),而不是文生图;已验证可正常支持 tool calling 与视觉功能的模型:Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条);拖放的图像也会作为 Chat 面板的附件共享(在该处显示并用于图生图生成),直到点击 ✕ 按钮清除",
|
||||
helpAi5: "设置面板:选择后端(Ollama / LM Studio / Lemonade)、设置 API URL、测试连接、选择模型、配置 Free 语言名称",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade)、设置 API URL、测试连接、选择模型、配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi7: "Chat 面板 — SVG:LLM 还可以直接以文本形式生成 SVG 图形,完全不涉及 ComfyUI 工作流;只需要求它用 SVG 绘制内容,如果回复中包含 SVG 代码,会自动渲染为内嵌预览,并提供\"保存到图库\"按钮,将其保存为 .svg 文件到 Gallery 标签页的输出文件夹;该功能不使用 tool calling(函数调用),因此不支持 tool calling 的模型也可以使用",
|
||||
helpImageEditTitle: "图像编辑选项卡",
|
||||
helpImageEdit1: "基于图层的图像编辑器 — 通过多个图层(图像、文字、绘制、遮罩类型)合成图像并导出",
|
||||
helpImageEdit2: "加载图像:拖放到画布、点击Upload按钮、或通过Gallery标签工具栏的Image Edit按钮发送;第一张图像成为图层1(自动锁定);后续图像缩放至画布大小作为新图层添加",
|
||||
helpImageEdit3: "New按钮:创建空白画布 — 输入宽高(如512x768);清除所有现有图层并自动添加空白Layer 1",
|
||||
helpImageEditToolsTitle: "工具",
|
||||
helpImageEdit4: "Select (V):点击选择图层;拖动移动;拖动角点手柄调整大小;拖动顶边圆形手柄旋转;双击文字图层可重新编辑;工具选项栏显示翻转H / 翻转V / 旋转角度",
|
||||
helpImageEdit5: "Draw (B):在当前绘制图层上自由绘画;工具选项:颜色、笔刷大小、混合模式;属性面板包含用于ABR图像笔刷的MASK EDITOR ONE (COLOR)区域 — 始终显示,笔刷可用时标题为绿色,未导入笔刷时为灰色;没有任何笔刷时Select按钮为禁用状态",
|
||||
helpImageEdit6: "Text (T):点击画布放置文字输入框;在工具选项栏设置字体、大小、粗体/斜体、对齐方式、颜色;点击OK后以精确文字边界框大小创建文字图层;用Select工具双击现有文字图层可重新编辑",
|
||||
helpImageEdit7: "Shape (S):在画布上绘制几何图形;每个确认的图形成为独立的绘制图层;工具选项:图形类型(Rect / Ellipse / Line / FreeLine)、圆角复选框(仅Rect/Ellipse)、填充颜色+None切换(Line/FreeLine隐藏)、描边颜色+宽度+None切换(Line/FreeLine的None隐藏,始终有描边)、不透明度滑块、↩撤销按钮;拖动预览(蓝色虚线),释放确认",
|
||||
helpImageEditLayerTitle: "图层面板",
|
||||
helpImageEdit8: "图层从前景(顶)到背景(底)列出;点击图层行将其激活",
|
||||
helpImageEdit9: "👁 / 🚫 按钮:切换图层可见性",
|
||||
helpImageEdit10: "🔒 / 🔓 按钮:切换图层锁定 — 锁定图层无法移动、缩放或旋转(显示橙色边框);加载第一张图像时图层1自动锁定以防止误操作",
|
||||
helpImageEdit11: "底部的不透明度滑块调整当前图层的透明度",
|
||||
helpImageEdit12: "类型图标:🖼 = 图像图层,T = 文字图层,✏ = 绘制图层,⬚ = 遮罩图层",
|
||||
helpImageEdit13: "Delete键:删除选中图层(需要2个及以上图层)",
|
||||
helpImageEditTextTitle: "文字质量",
|
||||
helpImageEdit14: "文字图层按测量的文字边界框精确调整大小 — 无多余空白",
|
||||
helpImageEdit15: "用Select工具调整文字图层大小时,以新的显示尺寸重新生成画布,保持文字清晰(无模糊)",
|
||||
helpImageEdit16: "重新编辑文字图层(双击)会重置为原始大小;如需调整请之后重新缩放",
|
||||
helpImageEditExportTitle: "导出与导航",
|
||||
helpImageEdit17: "Save PNG:合并所有可见图层并下载为PNG文件",
|
||||
helpImageEdit18: "Save to Gallery:输入文件名(默认:wfs-image-YYYYMMDDHHmmss)将合成PNG保存到Gallery根文件夹(在设置 > Gallery Output Folder中配置);需要至少打开过一次Gallery标签以设置根目录",
|
||||
helpImageEdit19: "Send to ComfyUI:通过/upload/image将合成PNG上传到ComfyUI的输入文件夹,使其可在Load Image节点中选择",
|
||||
helpImageEdit20: "画布导航:滚轮缩放,Space+拖动平移",
|
||||
helpImageEdit21: "撤销/重做:Ctrl+Z / Ctrl+Y(或工具栏的 ↩ ↪ 按钮)",
|
||||
helpImageEditKbdTitle: "图像编辑键盘快捷键",
|
||||
helpImageEdit22: "V:选择工具",
|
||||
helpImageEdit23: "B:绘制工具",
|
||||
helpImageEdit24: "T:文字工具",
|
||||
helpImageEdit25: "S:图形工具",
|
||||
helpImageEdit26: "Delete:删除选中图层(需要2个及以上图层)",
|
||||
helpImageEdit27: "Ctrl+Z / Ctrl+Y:撤销/重做",
|
||||
helpImageEdit28: "Space+拖动:平移画布",
|
||||
helpImageEdit29: "鼠标滚轮:放大/缩小",
|
||||
helpImageEdit30: "双击(Select工具点击文字图层):重新编辑文字",
|
||||
helpImageEdit7b: "Blur (≈):对活动图层应用高斯模糊或马赛克 — 整层或拖动矩形区域;详细内容参见下方「Blur Tool」卡片",
|
||||
helpImageEdit7c: "BG Remove (⬚):使用Lightweight(@imgly,在浏览器中运行)或BiRefNet(需要Mask Editor One + ComfyUI/models/background_removal/中的birefnet.safetensors)去除活动图层背景;详细内容参见下方「BG Remove Tool」卡片",
|
||||
helpImageEdit7d: "Mask (🎭):使用6种子工具在遮罩图层上绘制遮罩 — Paint(徒手绘制)、Color(按颜色相似度泛选)、Alpha(从图像透明度提取)、Text(文字图章)、Vector(Catmull-Rom样条多边形)、Shape(矩形/椭圆);安装Mask Editor One后还可使用SAM3文本提示分割;点击图层标题栏的「M」添加遮罩图层;详细内容参见下方「Mask Tool」卡片",
|
||||
helpImageEdit7e: "Inpaint (🩹):将可见图层与遮罩图层合成为一张RGBA图像(遮罩烘焙进alpha通道),并应用到GenerateUI当前加载的工作流中第一个支持内绘的LoadImage节点(其MASK输出连接到VAEEncodeForInpaint等节点);在属性面板中输入正/负向提示词、Grow Mask By(应用到VAE Encode (for Inpainting)节点)、Denoise(应用到KSampler)后点击Run,即可在不离开Image Edit标签的情况下完成上传与生成;完成后结果图像会显示在属性面板中;需先绘制遮罩图层并在GenerateUI中加载支持内绘的工作流;同样支持基于Mask Editor One自定义节点(comfyui-mask-editor-one)而非LoadImage构建的工作流(通过其按node_id缓存的服务端图像与layer_data组件写入图像/遮罩);「Use dedicated workflow」复选框:关闭时使用GenerateUI当前加载的工作流(与之前相同);开启时可从下拉列表选择已保存的工作流,将在后台加载、编辑并生成,不影响GenerateUI中显示的工作流",
|
||||
helpImageEdit8b: "图层标题栏的「M」按钮:在当前活动图层上方添加新的遮罩图层,并自动切换至Mask工具",
|
||||
helpImageEditMaskTitle: "Mask 工具 (🎭)",
|
||||
helpImageEditMask1: "添加遮罩图层:点击图层面板标题栏的「M」按钮;新遮罩图层插入到活动图层上方,Mask工具自动激活",
|
||||
helpImageEditMask2: "在图层列表中点击遮罩图层会自动切换至Mask工具",
|
||||
helpImageEditMask3: "顶部工具栏 — 子工具:Paint / Color / Alpha / Text / Vector / Shape(均无需Mask Editor One)/ SAM3(需要Mask Editor One,未安装时不可用);Invert复选框反转叠加预览;Overlay拾色器更改预览颜色(默认红色);Blur滑块(0–50 px)柔化叠加边缘;叠加显示仅在当前激活的蒙版图层上显示,未激活的蒙版图层在被选中前保持隐藏",
|
||||
helpImageEditMask4: "属性面板 — Paint模式:Mode(Add / Erase)、Size(1–200 px)、Hardness(0–100%);MASK EDITOR ONE区域(始终显示 — 有可用的ABR笔刷时标题为绿色,尚未导入笔刷时为灰色):Select按钮打开ABR笔刷库(没有任何笔刷时禁用) — 选择笔刷将替换默认圆形笔刷;✕恢复Circle(默认);选择图像笔刷后Hardness禁用,并显示额外控件:Spacing(印章间距 5–100%)、Angle(旋转角度 0–359°)、Sz Jitter(每个印章的随机大小变化 0–100%)、Rot. Jitter(每个印章的随机旋转 on/off)",
|
||||
helpImageEditMask5: "遮罩图层行的 ✂ 按钮:切换是否作为剪贴蒙版使用 — 启用(蓝色)时,遮罩将裁剪其正下方图层,仅显示已绘制区域;剪贴效果在保存PNG / Gallery / 发送至ComfyUI时均会生效",
|
||||
helpImageEditMask6: "SAM3(需要Mask Editor One):由SAM3模型驱动的文本提示分割;输入目标对象描述(如\"cat\"、\"person\"),设置Max(最大蒙版候选数:3/6/9/12),然后点击Segment",
|
||||
helpImageEditMask7: "SAM3操作流程:Segment后,候选蒙版缩略图显示在属性面板 — 点击选择(蓝色边框 + ✓);可多选;点击Apply Selected(N)批量应用;Mode(Add / Erase)控制蒙版与当前遮罩图层的合并方式;若活动图层非遮罩图层则自动创建新遮罩图层",
|
||||
helpImageEditMask8: "ABR笔刷库(需要Mask Editor One,仅Paint模式):点击MASK EDITOR ONE区域的Select按钮打开笔刷选择器 — 左侧浏览文件夹,点击缩略图应用;「Round Brush (default)」恢复圆形笔刷;笔刷与Mask Editor One共享,存储在其brushes/文件夹",
|
||||
helpImageEditMask9: "Color子工具:点击画布中任意位置,选取与该点颜色相近的像素作为遮罩 — 属性面板:Mode(Add / Subtract)、Tolerance(0–255,颜色容差)、Feather %(0–100,容差范围内的边缘柔化量)",
|
||||
helpImageEditMask10: "Alpha子工具:将图像图层的透明通道提取为遮罩 — 属性面板:Threshold(Alpha ≥ 此值的像素成为完整遮罩;0 = 包含全部透明度信息)、Invert(反转提取结果)、Extract Alpha按钮执行操作;基于所有可见非遮罩图层的合成结果提取",
|
||||
helpImageEditMask11: "Text子工具:点击画布打开文字图章面板;输入文字,设置Font / Size / Bold / Italic / Align及Mode(Add = 白色图章 / Erase = 消除遮罩),点击Stamp或按Ctrl+Enter确认;支持多行文字(在文本框中换行)",
|
||||
helpImageEditMask12: "Vector子工具:点击添加Catmull-Rom样条控制点;点击第一个控制点(或按Enter)关闭并填充多边形;Backspace删除最后一个点;Esc重置所有点;属性面板显示当前点数及Mode(Add / Erase)",
|
||||
helpImageEditMask13: "Shape子工具:拖动绘制矩形或椭圆遮罩;按住Shift可绘制正方形/正圆;拖动时蓝色虚线预览形状;属性面板:Mode(Add / Erase)、Shape(Rect / Ellipse)",
|
||||
helpImageEditBlurTitle: "Blur 工具 (≈)",
|
||||
helpImageEditBlur1: "Whole Blur:对整个活动图层应用高斯模糊 — 调整强度滑块(1–50 px),点击Apply",
|
||||
helpImageEditBlur2: "Whole Mosaic:对整个活动图层应用像素化马赛克 — 调整块大小滑块(5–100 px),点击Apply",
|
||||
helpImageEditBlur3: "Rect Blur:点击切换按钮进入矩形模式(按钮变蓝),在画布上拖动绘制矩形 — 释放后对选区应用模糊;可在启用模式前后调整强度",
|
||||
helpImageEditBlur4: "Rect Mosaic:与Rect Blur相同但应用马赛克;预览轮廓为橙色;用滑块调整块大小",
|
||||
helpImageEditBlur5: "Rect Blur和Rect Mosaic互斥 — 启用一个会禁用另一个;再次点击活动切换按钮退出矩形模式",
|
||||
helpImageEditBlur6: "所有操作均支持撤销(Ctrl+Z)",
|
||||
helpImageEditBgRemoveTitle: "BG Remove 工具 (⬚)",
|
||||
helpImageEditBgRemove1: "模型 — Lightweight(@imgly):使用从CDN加载的@imgly/background-removal库在浏览器内处理;无需服务器;首次使用时下载模型(约40 MB)并在会话期间缓存",
|
||||
helpImageEditBgRemove2: "模型 — BiRefNet:使用BiRefNet模型进行高质量背景去除;需要安装Mask Editor One并将birefnet.safetensors放置在ComfyUI/models/background_removal/",
|
||||
helpImageEditBgRemove3: "New Layer(默认勾选):结果作为新图层添加到活动图层上方,位置和显示尺寸相同 — 原始图层保留",
|
||||
helpImageEditBgRemove4: "取消勾选New Layer时,活动图层的画布将被去除背景的结果直接替换",
|
||||
helpImageEditBgRemove5: "点击Remove BG开始处理;状态文字显示「Loading model → Processing → Done」;支持撤销(Ctrl+Z)",
|
||||
helpImageEditGmicTitle: "G'MIC 滤镜(用G'MIC编辑)",
|
||||
helpImageEditGmic1: "对活动图像图层应用G'MIC-Qt滤镜效果;处理结果将作为新图层加载",
|
||||
helpImageEditGmic2: "点击工具栏中的「用G'MIC编辑」按钮,以当前图层图像启动G'MIC-Qt GUI",
|
||||
helpImageEditGmic3: "在G'MIC-Qt中选择滤镜并调整参数后点击OK — 将显示「G'MIC-Qt filter output」窗口;关闭该窗口后结果将反映到Image Edit选项卡",
|
||||
helpImageEditGmic4: "需要安装G'MIC-Qt Standalone并在设置的「G'MIC-Qt Executable Path」中配置路径;仅支持图像类型图层",
|
||||
helpImageEditGmicInstallTitle: "G'MIC-Qt 安装方法",
|
||||
helpImageEditGmicInstall1: "从 gmic.eu/download.html 下载G'MIC-Qt Standalone — 选择适合您操作系统的包(Windows: gmic_qt-win64.zip)",
|
||||
helpImageEditGmicInstall2: "将zip解压到任意文件夹(如 C:\\tools\\gmic);无需安装程序",
|
||||
helpImageEditGmicInstall3: "打开设置 → G'MIC-Qt Executable Path,输入 gmic_qt.exe 的完整路径(如 C:\\tools\\gmic\\gmic_qt.exe),点击 Save",
|
||||
helpImageEditTabKbdTitle: "图像编辑选项卡快捷键",
|
||||
helpShortcutsTitle: "键盘操作",
|
||||
helpShortcuts1: "Escape:关闭弹窗/面板",
|
||||
helpShortcuts2: "Enter:确认重命名、发送聊天",
|
||||
@@ -2497,12 +2881,13 @@ const LANGUAGES = {
|
||||
helpShortcuts4: "双击:打开详情弹窗",
|
||||
helpTroubleTitle: "故障排除",
|
||||
helpTrouble1: "无法连接ComfyUI:确认ComfyUI能否生成图片、在设置中验证URL、尝试刷新或重启",
|
||||
helpTrouble2: "无法连接AI助手:使用设置标签页的连接测试按钮;Ollama需确认已启动且模型已下载;LM Studio需确认服务器已启动且模型已加载",
|
||||
helpTrouble2: "无法连接AI助手:使用设置标签页的连接测试按钮;Ollama需确认已启动且模型已下载;LM Studio或Lemonade需确认服务器已启动且模型已加载",
|
||||
helpTrouble3: "工作流不显示:点击刷新按钮、确认能在ComfyUI中加载、尝试重启浏览器/ComfyUI",
|
||||
helpTrouble4: "图片生成失败:确认指定模型存在、验证ComfyUI连接、尝试重启",
|
||||
helpTrouble5: "LoRA Stack出现「No such file or directory」错误:该LoRA可能未被comfyui-lora-manager识别(已知问题)。请尝试其他LoRA,或检查该文件是否显示在Lora Manager自身的界面中",
|
||||
helpTrouble6: "移动或删除模型后出现模型加载错误:ComfyUI会缓存文件路径,可能仍引用旧位置。请通过ComfyUI菜单执行Hard Reset(Queue → Hard Reset)或重启ComfyUI以清除缓存。",
|
||||
helpTrouble7: "Tagger标签不显示模型:确认ComfyUI/models/tagger/<模型名>/文件夹存在且包含.onnx或.h5文件;如未安装onnxruntime,请运行:python_embeded\\python.exe -m pip install onnxruntime",
|
||||
helpTrouble8: "Model 标签页显示的 Checkpoint 与预期不符(例如无论加载哪个工作流都显示相同模型):工作流文件中保存的 Checkpoint 实际未安装,加载时已被静默替换为其他已安装模型,此时会弹出包含原始(未找到)模型名称的错误提示。请在 Model 标签页手动选择正确的 Checkpoint,或在设置标签页启用\"默认 Checkpoint\",在每次加载时强制应用一个已验证可用的模型。",
|
||||
helpSupportTitle: "支持",
|
||||
helpSupportDesc: "如果您觉得这个插件有用,请考虑支持它的开发!",
|
||||
helpGithubDesc: "报告问题、请求功能和贡献代码",
|
||||
@@ -2526,6 +2911,10 @@ const LANGUAGES = {
|
||||
comfyuiConnection: "ComfyUI 连接",
|
||||
comfyuiUrl: "ComfyUI URL",
|
||||
comfyuiUrlHint: "作为ComfyUI插件运行时留空(推荐同源)。仅在不同服务器时设置。",
|
||||
defaultCheckpointSettings: "默认 Checkpoint",
|
||||
defaultCheckpointEnable: "加载工作流时强制应用默认 Checkpoint",
|
||||
defaultCheckpointModel: "默认 Checkpoint 模型",
|
||||
defaultCheckpointHint: "启用后,每次将工作流加载到 GenerateUI 时,所有 Checkpoint 加载节点的模型都会被覆盖为此值,用于避免工作流文件中保存的模型与当前环境不匹配导致生成错误或图像模糊。",
|
||||
connectedCheck: "已连接 \u2713",
|
||||
failedConnect: "连接失败 \u2717",
|
||||
ollamaSettings: "AI助手设置(提示词标签页)",
|
||||
@@ -2534,6 +2923,8 @@ const LANGUAGES = {
|
||||
selectModel: "选择模型...",
|
||||
noModelsFound: "未找到模型",
|
||||
failedLoadModels: "获取模型列表失败",
|
||||
modelNotFound: "未找到",
|
||||
modelNotFoundHint: "此工作流中保存的模型在已安装的模型列表中未找到,请从列表中选择正确的模型。",
|
||||
saveOllama: "保存AI助手设置",
|
||||
testConnection: "测试连接",
|
||||
ollamaConnected: "已连接 \u2713",
|
||||
@@ -2643,7 +3034,7 @@ const LANGUAGES = {
|
||||
helpMetadata2: "中列:从工作流中提取的 Checkpoint、VAE、扩散模型、文本编码器列表",
|
||||
helpMetadata3: "右列:LoRA 列表(含强度值)和提示词列表(点击在下方显示全文)",
|
||||
helpMetadata4: "提示词操作 — Copy:复制到剪贴板;GenUI:P/N:设置 GenerateUI 正/负向提示;Prompt:P/N:设置 Prompt 标签页预设正/负向提示",
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image 子图工作流)、SD WebUI、SD Forge、Fooocus。",
|
||||
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image / Mage-Flow 子图工作流)、SD WebUI、SD Forge、Fooocus。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+2631
-82
File diff suppressed because it is too large
Load Diff
@@ -2,58 +2,105 @@
|
||||
* 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");
|
||||
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 stamp canvas
|
||||
// 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._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() {
|
||||
this.canvas.style.cursor = "crosshair";
|
||||
if (this.canvas) this.canvas.style.cursor = "none";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this._drawing = false;
|
||||
this.canvas.style.cursor = "";
|
||||
this._clearStrokeBuffer();
|
||||
if (this.canvas) this.canvas.style.cursor = "";
|
||||
}
|
||||
|
||||
onMouseDown(x, y) {
|
||||
this._drawing = true;
|
||||
this._lastX = x;
|
||||
this._lastY = y;
|
||||
this._paint(x, y);
|
||||
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;
|
||||
this._paintLine(this._lastX, this._lastY, x, y);
|
||||
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;
|
||||
}
|
||||
@@ -61,6 +108,7 @@ export class DrawTool {
|
||||
onMouseUp() {
|
||||
if (this._drawing) {
|
||||
this._drawing = false;
|
||||
this._clearStrokeBuffer();
|
||||
if (this._onChange) this._onChange();
|
||||
}
|
||||
}
|
||||
@@ -69,6 +117,83 @@ export class DrawTool {
|
||||
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 &&
|
||||
@@ -122,6 +247,93 @@ export class DrawTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -25,8 +25,10 @@ export class Layer {
|
||||
this.rotation = 0; // 度
|
||||
this.flipX = false;
|
||||
this.flipY = false;
|
||||
this.textProps = null; // テキストレイヤー用プロパティ(再編集・再描画に使用)
|
||||
this.locked = false; // ロック中は SelectTool の変形を禁止
|
||||
this.textProps = null; // テキストレイヤー用プロパティ(再編集・再描画に使用)
|
||||
this.locked = false; // ロック中は SelectTool の変形を禁止
|
||||
this.maskApply = false; // マスクレイヤー: true=クリッピングマスクとして機能
|
||||
this.operation = "add"; // マスクレイヤー合成モード: "add" | "subtract"
|
||||
}
|
||||
|
||||
clear() {
|
||||
@@ -38,13 +40,23 @@ export class Layer {
|
||||
thumb.width = size;
|
||||
thumb.height = size;
|
||||
const ctx = thumb.getContext("2d");
|
||||
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);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -55,8 +67,10 @@ export class Layer {
|
||||
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,
|
||||
textProps: this.textProps ?? null,
|
||||
locked: this.locked ?? false,
|
||||
maskApply: this.maskApply ?? false,
|
||||
operation: this.operation ?? "add",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,8 +89,10 @@ export class Layer {
|
||||
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.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 => {
|
||||
@@ -174,6 +190,19 @@ export class LayerManager {
|
||||
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"); }
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
+46
-12
@@ -6,6 +6,7 @@
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
// ── File size limit ───────────────────────────────────────────
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
@@ -244,11 +245,22 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const name = inp.name ?? "";
|
||||
const isPos = name === "positive" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const text = srcNode.widgets_values?.[widgetIdx];
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(originId);
|
||||
if (!text) continue;
|
||||
const name = inp.name ?? "";
|
||||
if (name === "positive" || name.startsWith("positive")) pos.add(text);
|
||||
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler) return null;
|
||||
@@ -346,10 +358,20 @@ function extractPromptsAPI(wf) {
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const isPos = key === "positive" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit — one node carries both prompt & negative_prompt
|
||||
// directly, so textMap (single text per node) can't represent both roles.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit") {
|
||||
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
const text = textMap.get(String(val[0]));
|
||||
if (!text) continue;
|
||||
if (key === "positive" || key.startsWith("positive")) pos.add(text);
|
||||
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
|
||||
@@ -451,10 +473,22 @@ async function extractAllMetadata(file) {
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
|
||||
function fromWorkflow(wf, source) {
|
||||
async function fromWorkflow(originalWf, source) {
|
||||
let wf = originalWf;
|
||||
// サブグラフを持つワークフローは、外側のサブグラフノードに実際に設定された値
|
||||
// (ユーザーが見ている値)が内部ノードのwidgets_valuesへ反映されていないことがある
|
||||
// (テンプレートの古いデフォルト値のまま)。convertUiToApi() はこの注入も含めて
|
||||
// 正しく解決するため、そちらを経由してから抽出する。通常のワークフローには影響しない。
|
||||
if (Array.isArray(wf?.nodes) && wf.definitions?.subgraphs?.length > 0) {
|
||||
try {
|
||||
const apiWf = await comfyWorkflow.convertUiToApi(wf);
|
||||
if (apiWf && Object.keys(apiWf).length > 0) wf = apiWf;
|
||||
} catch { /* 変換失敗時は元のUI形式のまま抽出(既存ロジックにフォールバック) */ }
|
||||
}
|
||||
const base = { source, checkpoints: extractCheckpoints(wf), vaes: extractVAEs(wf), diffusionModels: extractDiffusionModels(wf), textEncoders: extractTextEncoders(wf), loras: extractLoRAs(wf), ...extractPrompts(wf) };
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)
|
||||
const mdm = extractMarkdownNoteModels(wf);
|
||||
// MarkdownNote からモデル情報を補完(subgraph形式の flux/qwen/z-image など)。
|
||||
// API変換後は nodes 情報が失われるため、常に元のUI形式ワークフローから抽出する。
|
||||
const mdm = extractMarkdownNoteModels(originalWf);
|
||||
if (mdm) {
|
||||
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
|
||||
if (!base.vaes.length) base.vaes = mdm.vaes;
|
||||
@@ -467,20 +501,20 @@ async function extractAllMetadata(file) {
|
||||
|
||||
if (isJSON) {
|
||||
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
|
||||
return wf ? fromWorkflow(wf, "comfyui") : null;
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
if (isWebP) {
|
||||
const exif = await readWebPEXIFChunk(file);
|
||||
if (!exif) return null;
|
||||
const wf = extractWorkflowFromEXIF(exif);
|
||||
return wf ? fromWorkflow(wf, "comfyui") : null;
|
||||
return wf ? await fromWorkflow(wf, "comfyui") : null;
|
||||
}
|
||||
// PNG
|
||||
const chunks = await readAllPNGTextChunks(file);
|
||||
if (!chunks) return null;
|
||||
|
||||
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.prompt) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.prompt)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
|
||||
if (chunks.workflow) { let wf; try { wf = JSON.parse(sanitizeJSON(chunks.workflow)); } catch { return null; } return wf ? await fromWorkflow(wf, "comfyui") : null; }
|
||||
|
||||
if (chunks.fooocus_scheme === "fooocus" && chunks.parameters) {
|
||||
const f = parseFooocusMetadata(chunks.parameters);
|
||||
|
||||
+68
-10
@@ -7,7 +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 } from "./util.js";
|
||||
import { escapeHtml, readJsonStorage, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────
|
||||
const RESERVED_GROUPS = ["Batch", "Stack"];
|
||||
@@ -130,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
|
||||
@@ -157,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}`);
|
||||
}
|
||||
|
||||
@@ -451,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();
|
||||
@@ -651,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");
|
||||
@@ -947,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}`
|
||||
: "";
|
||||
@@ -1370,6 +1386,7 @@ function openDetailModal(modelName) {
|
||||
? `<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>`;
|
||||
@@ -1430,6 +1447,11 @@ function openDetailModal(modelName) {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
|
||||
// Close button (連続作業時にXボタンまでマウス移動しなくて済むように)
|
||||
document.getElementById("wfm-modal-model-close")?.addEventListener("click", () => {
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Delete model button
|
||||
document.getElementById("wfm-modal-model-delete")?.addEventListener("click", async () => {
|
||||
const confirmMsg = t("modelBulkDeleteConfirm").replace("{count}", "1");
|
||||
@@ -2303,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) => {
|
||||
@@ -2388,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) {
|
||||
|
||||
+238
-3
@@ -5,7 +5,7 @@
|
||||
import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +26,8 @@ const state = {
|
||||
activeSubView: "browser",
|
||||
loaded: false,
|
||||
currentPage: 0,
|
||||
selectedNodes: new Set(),
|
||||
lastSelectionIndex: -1,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
@@ -273,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)}`;
|
||||
|
||||
@@ -294,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;
|
||||
}
|
||||
|
||||
@@ -319,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;
|
||||
@@ -332,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);
|
||||
@@ -351,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) {
|
||||
@@ -1044,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 => {
|
||||
@@ -1109,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,7 +4,7 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -93,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 */ }
|
||||
@@ -364,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();
|
||||
});
|
||||
@@ -393,7 +390,7 @@ let _promptAiBackendForModels = null;
|
||||
function getPromptAiConfig() {
|
||||
const s = loadPromptAiSettings();
|
||||
const backend = s.backend || "ollama";
|
||||
const url = (s.backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
|
||||
const url = (s.backendUrl || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
return { backend, url };
|
||||
}
|
||||
|
||||
@@ -421,7 +418,7 @@ async function chatWithAi(model, messages) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
} else {
|
||||
// LM Studio: convert Ollama-style messages (with .images) to OpenAI content arrays
|
||||
// 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 {
|
||||
|
||||
+104
-6
@@ -6,7 +6,7 @@ import { showToast } from "./app.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions, getSummaryLanguageOptions } from "./i18n.js";
|
||||
|
||||
import { getSettings, readJsonStorage } from "./util.js";
|
||||
import { getSettings, readJsonStorage, getAiBackendDefaultUrl } from "./util.js";
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
@@ -637,6 +637,30 @@ export async function initSettingsTab() {
|
||||
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- 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>
|
||||
@@ -649,6 +673,9 @@ export async function initSettingsTab() {
|
||||
<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">
|
||||
@@ -738,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>
|
||||
@@ -1083,14 +1128,14 @@ export async function initSettingsTab() {
|
||||
const promptAiUrlEl = document.getElementById("wfm-settings-prompt-ai-url");
|
||||
if (promptAiUrlEl) {
|
||||
promptAiUrlEl.value = promptAiSaved.backendUrl || "";
|
||||
promptAiUrlEl.placeholder = promptAiBackendSaved === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
promptAiUrlEl.placeholder = getAiBackendDefaultUrl(promptAiBackendSaved);
|
||||
}
|
||||
|
||||
// Backend change → update URL placeholder
|
||||
// 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.placeholder = backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
if (promptAiUrlEl) promptAiUrlEl.value = getAiBackendDefaultUrl(backend);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1099,7 +1144,7 @@ export async function initSettingsTab() {
|
||||
const select = document.getElementById("wfm-settings-prompt-ai-model");
|
||||
if (!select) return;
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
try {
|
||||
let models = [];
|
||||
if (backend === "ollama") {
|
||||
@@ -1124,7 +1169,7 @@ export async function initSettingsTab() {
|
||||
document.getElementById("wfm-settings-prompt-ai-test")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-prompt-ai-status");
|
||||
const backend = document.querySelector('input[name="wfm-prompt-ai-backend"]:checked')?.value || "ollama";
|
||||
const url = (promptAiUrlEl?.value.trim() || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234")).replace(/\/$/, "");
|
||||
const url = (promptAiUrlEl?.value.trim() || getAiBackendDefaultUrl(backend)).replace(/\/$/, "");
|
||||
if (statusEl) { statusEl.textContent = t("aiStatusConnecting"); statusEl.style.color = "var(--wfm-text-secondary)"; }
|
||||
try {
|
||||
let count = 0;
|
||||
@@ -1192,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");
|
||||
@@ -1415,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)";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,3 +26,41 @@ export function readJsonStorage(key, fallback = {}) {
|
||||
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();
|
||||
}
|
||||
|
||||
+116
-4
@@ -10,7 +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 } from "./util.js";
|
||||
import { escapeHtml, getSettings, readJsonStorage, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
@@ -51,7 +51,7 @@ function sendToCanvas(workflowData) {
|
||||
// window.opener経由でComfyUIキャンバスに直接ロード(推奨)
|
||||
if (window.opener && typeof window.opener.wfmReceiveWorkflow === "function") {
|
||||
window.opener.wfmReceiveWorkflow(workflowData);
|
||||
showToast(t("workflowSentToCanvas"), "success");
|
||||
showToast(t("workflowSentToCanvasDirect"), "success");
|
||||
return;
|
||||
}
|
||||
// フォールバック: localStorage + タイトルドラッグ(UI形式のみ)
|
||||
@@ -270,6 +270,85 @@ async function getRawWorkflow(filename) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Summary — structured extraction (not raw JSON dump)
|
||||
// ============================================
|
||||
|
||||
// analyzeWorkflow() の結果をコンパクトなテキストに整形する。
|
||||
// 生JSONをそのままLLMに渡すと、MarkdownNoteの長文やUI座標情報などのノイズで
|
||||
// 文字数制限に達し、肝心のノード構成情報(サブグラフ内含む)が切り捨てられやすい。
|
||||
function _buildStructuredSummaryText(analysis) {
|
||||
const lines = [];
|
||||
|
||||
if (analysis.checkpoint_nodes.length) {
|
||||
lines.push(`Checkpoint: ${analysis.checkpoint_nodes.map((n) => n.ckpt_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.diffusion_model_nodes.length) {
|
||||
lines.push(`Diffusion Model (UNET): ${analysis.diffusion_model_nodes.map((n) => n.unet_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.text_encoder_nodes.length) {
|
||||
lines.push(`Text Encoder (CLIP): ${analysis.text_encoder_nodes.map((n) => [n.clip_name1, n.clip_name2].filter(Boolean).join("+")).join(", ")}`);
|
||||
}
|
||||
if (analysis.vae_nodes.length) {
|
||||
lines.push(`VAE: ${analysis.vae_nodes.map((n) => n.vae_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.lora_nodes.length) {
|
||||
lines.push(`LoRA: ${analysis.lora_nodes.map((n) => {
|
||||
if (n.is_lora_manager) return "(LoraManager)";
|
||||
const strengths = n.strength_clip !== undefined
|
||||
? `model:${n.strength_model}, clip:${n.strength_clip}`
|
||||
: `model:${n.strength_model}`;
|
||||
return `${n.lora_name} (${strengths})`;
|
||||
}).join(", ")}`);
|
||||
}
|
||||
if (analysis.controlnet_nodes.length) {
|
||||
lines.push(`ControlNet: ${analysis.controlnet_nodes.map((n) => n.control_net_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
if (analysis.hypernetwork_nodes.length) {
|
||||
lines.push(`Hypernetwork: ${analysis.hypernetwork_nodes.map((n) => n.hypernetwork_name).filter(Boolean).join(", ")}`);
|
||||
}
|
||||
|
||||
const positives = analysis.prompt_nodes.filter((n) => n.role === "positive").map((n) => n.text).filter(Boolean);
|
||||
const negatives = analysis.prompt_nodes.filter((n) => n.role === "negative").map((n) => n.text).filter(Boolean);
|
||||
if (positives.length) lines.push(`Positive Prompt: ${positives.join(" / ")}`);
|
||||
if (negatives.length) lines.push(`Negative Prompt: ${negatives.join(" / ")}`);
|
||||
|
||||
if (analysis.sampler_nodes.length) {
|
||||
const s = analysis.sampler_nodes[0];
|
||||
lines.push(`Sampler: steps=${s.steps}, cfg=${s.cfg}, sampler=${s.sampler_name}, scheduler=${s.scheduler}, denoise=${s.denoise}`);
|
||||
}
|
||||
if (analysis.latent_nodes.length) {
|
||||
const l = analysis.latent_nodes[0];
|
||||
lines.push(`Latent size: ${l.width ?? "?"}x${l.height ?? "?"}, batch=${l.batch_size ?? "?"}`);
|
||||
}
|
||||
|
||||
if (analysis.all_nodes.length) {
|
||||
const nodeTypes = [...new Set(analysis.all_nodes.map((n) => n.type))];
|
||||
lines.push(`Node types used (${analysis.all_nodes.length} nodes total): ${nodeTypes.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ワークフロー(UI/API/サブグラフ入りいずれの形式でも)から要約用テキストを組み立てる。
|
||||
// 変換・解析に失敗した場合は、従来通り生JSON先頭部分にフォールバックする。
|
||||
async function _buildSummarySourceText(wfJson, filename) {
|
||||
try {
|
||||
const fmt = comfyWorkflow.detectFormat(wfJson, filename);
|
||||
let apiWf = wfJson;
|
||||
if (fmt === "ui") {
|
||||
apiWf = await comfyWorkflow.convertUiToApi(wfJson);
|
||||
} else if (fmt !== "api") {
|
||||
return JSON.stringify(wfJson).substring(0, 4000);
|
||||
}
|
||||
const analysis = comfyWorkflow.analyzeWorkflow(apiWf);
|
||||
const text = _buildStructuredSummaryText(analysis);
|
||||
return text || JSON.stringify(wfJson).substring(0, 4000);
|
||||
} catch {
|
||||
return JSON.stringify(wfJson).substring(0, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rendering Helpers
|
||||
// ============================================
|
||||
@@ -1128,11 +1207,20 @@ function openDetailModal(wf) {
|
||||
btn.textContent = t("summarizing");
|
||||
try {
|
||||
const wfJson = await getRawWorkflow(wf.filename);
|
||||
const promptText = getSummaryPrompt() + JSON.stringify(wfJson).substring(0, 4000);
|
||||
const sourceText = await _buildSummarySourceText(wfJson, wf.filename);
|
||||
const promptText = getSummaryPrompt() + sourceText;
|
||||
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);
|
||||
@@ -1227,6 +1315,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");
|
||||
@@ -1354,6 +1447,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();
|
||||
}
|
||||
|
||||
+255
-66
@@ -46,7 +46,10 @@
|
||||
</div>
|
||||
<!-- Row 2: Search, group filter, actions -->
|
||||
<div class="wfm-toolbar-row">
|
||||
<input type="text" id="wfm-search" class="wfm-search-input" placeholder="Search...">
|
||||
<div class="wfm-search-wrap">
|
||||
<input type="text" id="wfm-search" class="wfm-search-input" placeholder="Search...">
|
||||
<button class="wfm-search-clear-btn" id="wfm-search-clear-btn" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-group-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Groups</option>
|
||||
</select>
|
||||
@@ -67,6 +70,7 @@
|
||||
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
|
||||
</div>
|
||||
<button id="wfm-wf-batch-clear-btn" class="wfm-btn wfm-btn-sm" title="Clear all batch workflows">BC</button>
|
||||
<button id="wfm-wf-clear-filters-btn" class="wfm-btn wfm-btn-sm" title="Clear all filters"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-workflow-area">
|
||||
@@ -124,7 +128,10 @@
|
||||
<!-- Browser toolbar -->
|
||||
<div id="wfm-nodes-browser-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-filter-btn" id="wfm-nodes-fav-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-nodes-search" class="wfm-search-input" placeholder="Search nodes...">
|
||||
<div class="wfm-search-wrap">
|
||||
<input type="text" id="wfm-nodes-search" class="wfm-search-input" placeholder="Search nodes...">
|
||||
<button class="wfm-search-clear-btn" id="wfm-nodes-search-clear-btn" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-nodes-category-filter" class="wfm-select wfm-toolbar-select"><option value="">All Categories</option></select>
|
||||
<select id="wfm-nodes-package-filter" class="wfm-select wfm-toolbar-select"><option value="">All Packages</option></select>
|
||||
<select id="wfm-nodes-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
|
||||
@@ -135,9 +142,26 @@
|
||||
</div>
|
||||
<span id="wfm-nodes-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-nodes-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-nodes-clear-filters-btn" class="wfm-btn wfm-btn-sm" title="Clear all filters"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 複数選択時の一括操作バー(ブラウザエリア外に配置) -->
|
||||
<div id="wfm-nodes-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
|
||||
<span id="wfm-nodes-bulk-count" class="wfm-gallery-bulk-count"></span>
|
||||
<button id="wfm-nodes-bulk-deselect" class="wfm-btn wfm-btn-sm"></button>
|
||||
<span style="border-left:1px solid var(--wfm-border);height:18px;margin:0 2px;"></span>
|
||||
<select id="wfm-nodes-bulk-group-select" class="wfm-select" style="font-size:12px;padding:2px 4px;max-width:140px;"></select>
|
||||
<button id="wfm-nodes-bulk-group-add" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-nodes-bulk-group-remove" class="wfm-btn wfm-btn-sm"></button>
|
||||
<span style="border-left:1px solid var(--wfm-border);height:18px;margin:0 2px;"></span>
|
||||
<input type="text" id="wfm-nodes-bulk-new-group" class="wfm-input" style="font-size:12px;padding:2px 6px;width:130px;">
|
||||
<button id="wfm-nodes-bulk-create-add" class="wfm-btn wfm-btn-sm"></button>
|
||||
<span style="border-left:1px solid var(--wfm-border);height:18px;margin:0 2px;"></span>
|
||||
<button id="wfm-nodes-bulk-fav" class="wfm-btn wfm-btn-sm"></button>
|
||||
<button id="wfm-nodes-bulk-unfav" class="wfm-btn wfm-btn-sm"></button>
|
||||
</div>
|
||||
|
||||
<!-- Browser sub-view -->
|
||||
<div id="wfm-nodes-browser" class="wfm-workflow-area">
|
||||
<div id="wfm-nodes-grid" class="wfm-grid wfm-view-card">
|
||||
@@ -195,7 +219,10 @@
|
||||
<div id="wfm-models-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-filter-btn" id="wfm-models-fav-btn" title="Favorites">★</button>
|
||||
<button class="wfm-filter-btn" id="wfm-models-batch-filter-btn" title="Batch">B</button>
|
||||
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
|
||||
<div class="wfm-search-wrap">
|
||||
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
|
||||
<button class="wfm-search-clear-btn" id="wfm-models-search-clear-btn" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
|
||||
<select id="wfm-models-dir-filter" class="wfm-select wfm-toolbar-select"><option value="">All Folders</option></select>
|
||||
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
|
||||
@@ -216,6 +243,7 @@
|
||||
<button id="wfm-models-select-btn" class="wfm-btn wfm-btn-sm wfm-btn-success" title="Multi-select models">Select</button>
|
||||
<button id="wfm-models-batch-clear-btn" class="wfm-btn wfm-btn-sm" title="Clear all batch entries">BC</button>
|
||||
<button id="wfm-models-stack-clear-btn" class="wfm-btn wfm-btn-sm" title="Clear all stack entries (Lora)">SC</button>
|
||||
<button id="wfm-models-clear-filters-btn" class="wfm-btn wfm-btn-sm" title="Clear all filters"></button>
|
||||
</div>
|
||||
<!-- Bulk action bar (shown in select mode) -->
|
||||
<div id="wfm-models-bulk-bar" class="wfm-bulk-bar" style="display:none;"></div>
|
||||
@@ -260,6 +288,11 @@
|
||||
<button id="wfm-gen-connect-btn" class="wfm-btn wfm-btn-sm">Connect</button>
|
||||
<button id="wfm-gen-model-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh Models</button>
|
||||
<button id="wfm-gen-reset-workflow-btn" class="wfm-btn wfm-btn-sm">Reset Workflow</button>
|
||||
<label id="wfm-style-label" style="display:flex;align-items:center;gap:4px;font-size:12px;cursor:pointer;white-space:nowrap;">
|
||||
<input type="checkbox" id="wfm-style-enabled" style="cursor:pointer;">
|
||||
Style
|
||||
</label>
|
||||
<select id="wfm-style-select" class="wfm-input" style="font-size:12px;padding:2px 6px;height:26px;min-width:120px;max-width:220px;"></select>
|
||||
</div>
|
||||
<div class="wfm-toolbar-right" style="display:flex;align-items:center;gap:8px;">
|
||||
<span id="wfm-gen-wf-name" style="color:var(--wfm-text-secondary);font-size:13px;">No workflow loaded</span>
|
||||
@@ -344,6 +377,7 @@
|
||||
<button class="wfm-batch-left-tab active" data-left-tab="checkpoint">Checkpoints</button>
|
||||
<button class="wfm-batch-left-tab" data-left-tab="sampler">Sampler</button>
|
||||
<button class="wfm-batch-left-tab" data-left-tab="scheduler">Scheduler</button>
|
||||
<button class="wfm-batch-left-tab" data-left-tab="style">Style</button>
|
||||
</div>
|
||||
<div id="wfm-batch-left-checkpoint" class="wfm-batch-left-content active">
|
||||
<div class="wfm-batch-toolbar">
|
||||
@@ -367,6 +401,13 @@
|
||||
</div>
|
||||
<div id="wfm-scheduler-list" class="wfm-batch-list"></div>
|
||||
</div>
|
||||
<div id="wfm-batch-left-style" class="wfm-batch-left-content">
|
||||
<div class="wfm-batch-toolbar">
|
||||
<button type="button" id="wfm-style-batch-select-all" class="wfm-btn wfm-btn-sm">All</button>
|
||||
<button type="button" id="wfm-style-batch-deselect-all" class="wfm-btn wfm-btn-sm">None</button>
|
||||
</div>
|
||||
<div id="wfm-batch-style-list" class="wfm-batch-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center pane: group-based selection with inner tabs -->
|
||||
@@ -447,6 +488,14 @@
|
||||
<div id="wfm-batch-scheduler-count" class="wfm-batch-count"></div>
|
||||
<div id="wfm-batch-scheduler-list" class="wfm-batch-preview-list"></div>
|
||||
</div>
|
||||
<div class="wfm-batch-queue-col">
|
||||
<div class="wfm-batch-queue-col-header">
|
||||
<span>Style</span>
|
||||
<input type="checkbox" class="wfm-batch-type-cb" data-batch-type="style" title="Style batch を有効にする">
|
||||
</div>
|
||||
<div id="wfm-batch-style-count" class="wfm-batch-count"></div>
|
||||
<div id="wfm-batch-style-preview-list" class="wfm-batch-preview-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,6 +751,7 @@
|
||||
<span class="wfm-gen-rawjson-label">Raw JSON</span>
|
||||
<button id="wfm-gen-apply-raw-btn" class="wfm-btn wfm-btn-sm">Apply</button>
|
||||
</div>
|
||||
<div id="wfm-gen-rawjson-bypass-note" class="wfm-gen-rawjson-bypass-note" style="display:none;"></div>
|
||||
<div class="wfm-rawjson-search">
|
||||
<input type="text" id="wfm-gen-raw-search" class="wfm-rawjson-search-input" placeholder="Search...">
|
||||
<span id="wfm-gen-raw-search-count" class="wfm-rawjson-search-count"></span>
|
||||
@@ -1037,21 +1087,24 @@
|
||||
<h3>GenerateUI Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gen-1">Connect/disconnect ComfyUI and check connection status</li>
|
||||
<li id="wfm-help-gen-2">Load workflows from Workflow tab (App format .app.json not supported); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; <b>Save</b> button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default</li>
|
||||
<li id="wfm-help-gen-2">Load workflows from Workflow tab (subgraph-containing workflows are supported, e.g. Flux.2 / Qwen-Image / Z-Image / Mage-Flow templates; only ComfyUI's simplified App format [.app.json / Linear Mode] is not); Reset Workflow button re-reads the current workflow file from disk and reloads the editor; <b>Save</b> button (right end of the tab bar) saves the current workflow — including any edits made in the editor — as a new .json file to the Workflow tab; a filename dialog appears with the current workflow name as the default; <b>Style</b> checkbox and dropdown (next to Reset Workflow): enable to apply a Fooocus-style JSON style to the positive and negative prompts at generation time — style files are loaded from <code>user/default/Workflow-Studio/style/*.json</code>; the style's <code>{prompt}</code> placeholder is replaced with the original prompt, or the style text is appended if no placeholder exists</li>
|
||||
<li id="wfm-help-gen-3">5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing</li>
|
||||
<li id="wfm-help-gen-4">Input tab: inner tabs — <b>Prompt</b> and <b>Image</b> (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an <b>Embeddings</b> selector at the bottom — select a model, set Weight (default 1.0), then click <b>Paste</b> to insert <code>(embedding:Name:weight)</code> at the cursor position of the last focused textarea (defaults to Positive if neither is focused)</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder, <b>Hypernetwork</b> (with Strength field) selectors with filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-4">Input tab: inner tabs — <b>Prompt</b> and <b>Image</b> (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an <b>Embeddings</b> selector at the bottom — select a model, set Weight (default 1.0), then click <b>Paste</b> to insert <code>(embedding:Name:weight)</code> at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a <b>↺</b> button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press <b>Ctrl+↑/↓</b> to adjust its emphasis weight in <code>(text:weight)</code> syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)</li>
|
||||
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement — seed input and mode selector stacked vertically for readability</li>
|
||||
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
|
||||
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
|
||||
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle (SVG output supported too — both the standard SaveSVGNode and comfyui-tosvg's Save SVG String node)</li>
|
||||
<li id="wfm-help-gen-11">Batch tab — enable one batch type at a time by checking the checkbox in the Batch Queue column header; Generate then runs through all queued items sequentially with Pause/Resume and Stop controls</li>
|
||||
<li id="wfm-help-gen-12">Batch tab — Left pane has 3 tabs: <b>Checkpoints</b> (folder tree with Filter/All/None; (root) header checkbox selects all), <b>Sampler</b> (KSampler sampler_name list with (root) checkbox), <b>Scheduler</b> (KSampler scheduler list with (root) checkbox); All/None toolbar buttons also available for each tab</li>
|
||||
<li id="wfm-help-gen-12">Batch tab — Left pane has 4 tabs: <b>Checkpoints</b> (folder tree with Filter/All/None; (root) header checkbox selects all), <b>Sampler</b> (KSampler sampler_name list with (root) checkbox), <b>Scheduler</b> (KSampler scheduler list with (root) checkbox), <b>Style</b> (flat list of all styles loaded from <code>Workflow-Studio/style/</code> with All/None buttons); All/None toolbar buttons available for each tab</li>
|
||||
<li id="wfm-help-gen-13">Batch tab — Center pane: group-based selection with inner tabs (Checkpoint | Lora | Prompt | Workflow); Checkpoint tab shows groups defined in the Models tab — check a group header to select all its members, or expand ▶ to check individual models; group membership always reflects the current Models tab state</li>
|
||||
<li id="wfm-help-gen-14">Batch tab — Right pane (Batch Queue): 6 columns — Checkpoint, Lora, Prompt, Workflow, Sampler, Scheduler; each column has an enable checkbox; only one type active at a time; count shown per column</li>
|
||||
<li id="wfm-help-gen-14">Batch tab — Right pane (Batch Queue): 7 columns — Checkpoint, Lora, Prompt, Workflow, Sampler, Scheduler, <b>Style</b>; each column has an enable checkbox; only one type active at a time; count shown per column</li>
|
||||
<li id="wfm-help-gen-15">Sampler/Scheduler batch: select one or more samplers or schedulers from the left pane, enable the column in Batch Queue, then Generate to run each value through the KSampler node sequentially; sampler/scheduler lists are fetched from ComfyUI (requires connection)</li>
|
||||
<li id="wfm-help-gen-17">Style batch: select styles in the left pane Style tab, enable the Style column in Batch Queue, then Generate — each selected style is applied in sequence to the positive and negative prompts; styles are applied to workflow copies so the loaded workflow is not modified; style files are re-read each time the page loads from <code>user/default/Workflow-Studio/style/*.json</code></li>
|
||||
<li id="wfm-help-gen-16">SPA-side wildcard expansion (A1111-style): 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</li>
|
||||
<li id="wfm-help-gen-18">Alt+Click any Apply button (Positive Prompt / Checkpoint / KSampler) to Apply & Generate in one step — applies the parameter to the workflow then immediately queues a generation; only fires when the target node exists in the loaded workflow</li>
|
||||
<li id="wfm-help-gen-19">Lora batch: for each 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; the prompt is automatically restored to its 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 is shown (Refresh or restart ComfyUI)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1114,7 +1167,7 @@
|
||||
<li id="wfm-help-metadata-2">Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow</li>
|
||||
<li id="wfm-help-metadata-3">Right column: LoRA list (with strength values) and Prompt list (click to view full text below)</li>
|
||||
<li id="wfm-help-metadata-4">Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative</li>
|
||||
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus. Model extraction covers UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, and QuadrupleCLIPLoader (e.g. HiDream 4-CLIP).</li>
|
||||
<li id="wfm-help-metadata-5">Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Mage-Flow subgraph workflows), SD WebUI, SD Forge, Fooocus. Model extraction covers UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, and QuadrupleCLIPLoader (e.g. HiDream 4-CLIP).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1150,6 +1203,7 @@
|
||||
<li id="wfm-help-nodes-4">Create, rename, delete node groups and assign/remove nodes</li>
|
||||
<li id="wfm-help-nodes-5">Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management</li>
|
||||
<li id="wfm-help-nodes-6">Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes</li>
|
||||
<li id="wfm-help-nodes-7">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</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1174,6 +1228,7 @@
|
||||
<li id="wfm-help-gallery-13"><b>Load GenUI</b> button (blue, in the detail panel tab bar): loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab and switches to it; shows a warning toast if no workflow is embedded or the format is not supported</li>
|
||||
<li id="wfm-help-gallery-14"><b>Tagger</b> button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging</li>
|
||||
<li id="wfm-help-gallery-16"><b>Image Edit</b> button (toolbar, next to Refresh): sends the currently selected image to the Image Edit tab; if layers already exist the image is added as a new layer scaled to fit the canvas, otherwise it becomes Layer 1</li>
|
||||
<li id="wfm-help-gallery-17"><b>Send GenUI Image</b> button (toolbar, next to Image Edit): uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; requires a workflow with at least one LoadImage node to be loaded first</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1217,7 +1272,7 @@
|
||||
<li id="wfm-help-sidepanel-13">I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF and QuadrupleCLIPLoader node types</li>
|
||||
<li id="wfm-help-sidepanel-14">Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center</li>
|
||||
<li id="wfm-help-sidepanel-15">Information tab — LoRA sub-tab: drag individual LoRA to place LoraLoader; "Multiple LORA" section at bottom places all LoRAs in a single Lora Loader (LoraManager) node; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy</li>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama or LM Studio; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
<li id="wfm-help-sidepanel-17">Title drag (Send to Canvas): after clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab), the 'Workflow Studio Library' title turns blue with a green ● — drag it onto the canvas to load the workflow; title resets automatically after loading</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1228,11 +1283,12 @@
|
||||
<h3 id="wfm-help-ai-title">AI TOOL Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-ai-1">4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously</li>
|
||||
<li id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</li>
|
||||
<li id="wfm-help-ai-3">Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history</li>
|
||||
<li id="wfm-help-ai-4">TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), then click Run to analyze with a vision model; result shown with Copy button</li>
|
||||
<li id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</li>
|
||||
<li id="wfm-help-ai-3">Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image ("generate a cat picture", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the "Allow image generation" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)</li>
|
||||
<li id="wfm-help-ai-4">TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), then click Run to analyze with a vision model; result shown with Copy button; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button</li>
|
||||
<li id="wfm-help-ai-5">TOOLS pane — Wildcards: select "Create wildcards" from the dropdown, enter a category name and count, click Run to generate plain-text wildcard entries one per line</li>
|
||||
<li id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio), set API URL, test connection, select model, configure Free language names; settings are shared with the Library panel's A tab via localStorage</li>
|
||||
<li id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</li>
|
||||
<li id="wfm-help-ai-7">Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a "Save to Gallery" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1256,63 +1312,124 @@
|
||||
|
||||
<div class="wfm-help-page" id="wfm-help-page-imageedit">
|
||||
<div class="wfm-help-card">
|
||||
<h3>Image Edit Tab</h3>
|
||||
<h3 id="wfm-help-imageedit-title">Image Edit Tab</h3>
|
||||
<ul>
|
||||
<li>Layer-based image editor — compose images with multiple layers (Image, Text, Draw types) and export the composite</li>
|
||||
<li><b>Loading images:</b> drag & drop onto the canvas, Upload button, or send from the Gallery tab via the <b>Image Edit</b> button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li><b>New</b> button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers</li>
|
||||
<li id="wfm-help-imageedit-1">Layer-based image editor — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite</li>
|
||||
<li id="wfm-help-imageedit-2">Loading images: drag & drop onto the canvas, Upload button, or send from the Gallery tab via the Image Edit button in the Gallery toolbar; the first image becomes Layer 1 (auto-locked); subsequent images are added as new layers scaled to fit the canvas</li>
|
||||
<li id="wfm-help-imageedit-3">New button: create a blank canvas — prompts for WxH size (e.g. 512x768); clears all existing layers and automatically adds an empty Layer 1</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3>Tools</h3>
|
||||
<h3 id="wfm-help-imageedit-tools-title">Tools</h3>
|
||||
<ul>
|
||||
<li><b>Select (V):</b> click to select a layer; drag to move; drag corner handles to resize; drag the circle above the top edge to rotate; double-click a text layer to re-edit its content; Tool Options bar shows Flip H / Flip V / Rotate field</li>
|
||||
<li><b>Draw (B):</b> freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode</li>
|
||||
<li><b>Text (T):</b> click on the canvas to place a text input overlay; configure font family, size, bold/italic, align, color in the Tool Options bar; click OK to place the text as an exact-size Text layer; double-click an existing text layer with the Select tool to re-edit</li>
|
||||
<li><b>Shape (S):</b> draw geometric shapes onto the canvas; each committed shape becomes an independent draw layer; Tool Options: <b>Shape</b> type (Rect / Ellipse / Line / FreeLine), <b>Rounded</b> checkbox (Rect / Ellipse only), <b>Fill</b> color + None toggle (hidden for Line / FreeLine), <b>Stroke</b> color + width + None toggle (None hidden for Line / FreeLine where stroke is always active), <b>Opacity</b> slider, <b>↩ Undo</b> button; drag to draw — a blue dashed outline previews the shape; release to commit</li>
|
||||
<li id="wfm-help-imageedit-4">Select (V): click to select a layer; drag to move; drag corner handles to resize; drag the circle above the top edge to rotate; double-click a text layer to re-edit its content; Tool Options bar shows Flip H / Flip V / Rotate field</li>
|
||||
<li id="wfm-help-imageedit-5">Draw (B): freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode; Properties pane includes a MASK EDITOR ONE (COLOR) section for ABR image brushes — always visible, header text is green when brushes are available, gray when none are imported yet; Select is disabled until at least one brush exists</li>
|
||||
<li id="wfm-help-imageedit-6">Text (T): click on the canvas to place a text input overlay; configure font family, size, bold/italic, align, color in the Tool Options bar; click OK to place the text as an exact-size Text layer; double-click an existing text layer with the Select tool to re-edit</li>
|
||||
<li id="wfm-help-imageedit-7">Shape (S): draw geometric shapes onto the canvas; each committed shape becomes an independent draw layer; Tool Options: Shape type (Rect / Ellipse / Line / FreeLine), Rounded checkbox (Rect / Ellipse only), Fill color + None toggle (hidden for Line / FreeLine), Stroke color + width + None toggle (None hidden for Line / FreeLine where stroke is always active), Opacity slider, ↩ Undo button; drag to draw — a blue dashed outline previews the shape; release to commit</li>
|
||||
<li id="wfm-help-imageedit-7b">Blur (≈): apply Gaussian blur or mosaic to the active layer — whole-layer or region-based (drag a rectangle); see the Blur Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7c">BG Remove (⬚): remove the background from the active layer using Lightweight (@imgly, runs in browser) or BiRefNet (requires Mask Editor One + birefnet.safetensors in ComfyUI/models/background_removal/); see the BG Remove Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7d">Mask (🎭): paint masks on a mask layer using 6 sub-tools — Paint (freehand), Color (flood-select by similar color), Alpha (extract from image transparency), Text (stamp text), Vector (Catmull-Rom spline polygon), Shape (rect / ellipse); SAM3 text-prompt segmentation is also available when Mask Editor One is installed; click "M" in the Layers header to add a mask layer; see the Mask Tool card below for details</li>
|
||||
<li id="wfm-help-imageedit-7e">Inpaint (🩹): composites the visible layers plus a Mask layer into one RGBA image (mask baked into the alpha channel) and applies it to the first inpaint-capable LoadImage node in the workflow currently loaded in GenerateUI (a node whose MASK output feeds something like VAEEncodeForInpaint); enter Positive/Negative prompts, Grow Mask By (applied to the VAE Encode (for Inpainting) node), and Denoise (applied to KSampler) in the properties panel, then click Run to upload and generate without leaving the Image Edit tab — the result is shown back in the properties panel when done; requires a Mask layer to be painted first and an inpaint-capable workflow loaded in GenerateUI; also works with workflows built around the Mask Editor One custom node (comfyui-mask-editor-one) instead of LoadImage — image/mask are pushed to it via its node_id-keyed server cache and layer_data widget; "Use dedicated workflow" checkbox: when off, the workflow currently loaded in GenerateUI is used (unchanged); when on, pick a saved workflow from the dropdown — it is loaded, edited, and generated entirely in the background without disturbing the workflow shown in GenerateUI</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3>Layer Panel</h3>
|
||||
<h3 id="wfm-help-imageedit-layer-title">Layer Panel</h3>
|
||||
<ul>
|
||||
<li>Layers are listed from front (top) to back (bottom); click a layer row to make it active</li>
|
||||
<li><b>👁 / 🚫</b> button: toggle layer visibility</li>
|
||||
<li><b>🔒 / 🔓</b> button: toggle layer lock — a locked layer cannot be moved, resized, or rotated (orange bounding box is shown); Layer 1 is locked automatically when the first image is loaded to prevent accidental moves</li>
|
||||
<li>Opacity slider at the bottom adjusts the active layer's transparency</li>
|
||||
<li>Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer</li>
|
||||
<li>Delete key: removes the selected layer (only when 2+ layers exist)</li>
|
||||
<li id="wfm-help-imageedit-8">Layers are listed from front (top) to back (bottom); click a layer row to make it active</li>
|
||||
<li id="wfm-help-imageedit-8b">"M" button in the Layers header: adds a new mask layer above the current layer and automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-9">👁 / 🚫 button: toggle layer visibility</li>
|
||||
<li id="wfm-help-imageedit-10">🔒 / 🔓 button: toggle layer lock — a locked layer cannot be moved, resized, or rotated (orange bounding box is shown); Layer 1 is locked automatically when the first image is loaded to prevent accidental moves</li>
|
||||
<li id="wfm-help-imageedit-11">Opacity slider at the bottom adjusts the active layer's transparency</li>
|
||||
<li id="wfm-help-imageedit-12">Type icons: 🖼 = image layer, T = text layer, ✏ = draw layer, ⬚ = mask layer</li>
|
||||
<li id="wfm-help-imageedit-13">Delete key: removes the selected layer (only when 2+ layers exist)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3>Text Object Quality</h3>
|
||||
<h3 id="wfm-help-imageedit-blur-title">Blur Tool (≈)</h3>
|
||||
<ul>
|
||||
<li>Text layers are sized exactly to the measured text bounding box — no extra whitespace</li>
|
||||
<li>When a text layer is resized with the Select tool, the canvas is regenerated at the new display size so the text remains sharp (no blurring)</li>
|
||||
<li>Re-editing a text layer (double-click) resets it to native size; re-scale afterward if needed</li>
|
||||
<li id="wfm-help-imageedit-blur-1">Whole Blur: applies a Gaussian blur to the entire active layer — adjust the intensity slider (1–50 px) and click Apply</li>
|
||||
<li id="wfm-help-imageedit-blur-2">Whole Mosaic: applies a pixelation mosaic to the entire active layer — adjust the block size slider (5–100 px) and click Apply</li>
|
||||
<li id="wfm-help-imageedit-blur-3">Rect Blur: click the toggle to enter rect mode (button turns blue), then drag on the canvas to draw a rectangle — the selected region is blurred on release; adjust intensity before or after enabling the mode</li>
|
||||
<li id="wfm-help-imageedit-blur-4">Rect Mosaic: same as Rect Blur but applies mosaic; preview outline is orange; adjust block size with the slider</li>
|
||||
<li id="wfm-help-imageedit-blur-5">Rect Blur and Rect Mosaic are mutually exclusive — enabling one disables the other; click the active toggle again to exit rect mode</li>
|
||||
<li id="wfm-help-imageedit-blur-6">All operations support Undo (Ctrl+Z)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3>Export & Navigation</h3>
|
||||
<h3 id="wfm-help-imageedit-bgremove-title">BG Remove Tool (⬚)</h3>
|
||||
<ul>
|
||||
<li><b>Save PNG:</b> composites all visible layers and downloads the result as a PNG file</li>
|
||||
<li><b>Save to Gallery:</b> prompts for a filename (default: <code>wfs-image-YYYYMMDDHHmmss</code>) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root</li>
|
||||
<li><b>Send to ComfyUI:</b> uploads the composite PNG to ComfyUI's input folder via <code>/upload/image</code>, making it selectable in Load Image nodes</li>
|
||||
<li><b>Canvas navigation:</b> scroll wheel to zoom, Space + drag to pan</li>
|
||||
<li><b>Undo / Redo:</b> Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)</li>
|
||||
<li id="wfm-help-imageedit-bgremove-1">Model — Lightweight (@imgly): runs entirely in the browser using the @imgly/background-removal library loaded from CDN; no server required; first use downloads the model (~40 MB) and caches it for the session</li>
|
||||
<li id="wfm-help-imageedit-bgremove-2">Model — BiRefNet: high-quality background removal using the BiRefNet model; requires Mask Editor One to be installed and birefnet.safetensors placed in ComfyUI/models/background_removal/</li>
|
||||
<li id="wfm-help-imageedit-bgremove-3">New Layer (checked by default): the result is added as a new layer above the active layer at the same position and display size — the original layer is preserved</li>
|
||||
<li id="wfm-help-imageedit-bgremove-4">When New Layer is unchecked, the active layer's canvas is replaced in-place with the background-removed result</li>
|
||||
<li id="wfm-help-imageedit-bgremove-5">Click Remove BG to start processing; status text shows Loading model → Processing → Done; supports Undo (Ctrl+Z)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3>Image Edit Keyboard Shortcuts</h3>
|
||||
<h3 id="wfm-help-imageedit-mask-title">Mask Tool (🎭)</h3>
|
||||
<ul>
|
||||
<li><b>V</b>: Select tool</li>
|
||||
<li><b>B</b>: Draw tool</li>
|
||||
<li><b>T</b>: Text tool</li>
|
||||
<li><b>S</b>: Shape tool</li>
|
||||
<li><b>Delete</b>: Delete selected layer (requires 2+ layers)</li>
|
||||
<li><b>Ctrl+Z / Ctrl+Y</b>: Undo / Redo</li>
|
||||
<li><b>Space + drag</b>: Pan canvas</li>
|
||||
<li><b>Mouse wheel</b>: Zoom in / out</li>
|
||||
<li><b>Double-click</b> (Select tool on text layer): Re-edit text</li>
|
||||
<li id="wfm-help-imageedit-mask-1">Add a mask layer: click the "M" button in the Layers panel header; the new mask layer is inserted above the active layer and the Mask tool activates automatically</li>
|
||||
<li id="wfm-help-imageedit-mask-2">Clicking a mask layer in the layer list automatically switches to the Mask tool</li>
|
||||
<li id="wfm-help-imageedit-mask-3">Tool Options bar — sub-tools: Paint / Color / Alpha / Text / Vector / Shape (all work without Mask Editor One) / SAM3 (requires Mask Editor One, disabled if not installed); Invert checkbox inverts the overlay preview; Overlay color picker changes the preview color (default red); Blur slider (0–50 px) softens the overlay edge; the overlay is only shown for the currently active mask layer — inactive mask layers stay hidden until selected</li>
|
||||
<li id="wfm-help-imageedit-mask-4">Properties pane — Paint mode: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); MASK EDITOR ONE section (always visible — header text is green when ABR brushes are available, gray when none are imported yet): Select button opens the ABR brush library (disabled until at least one brush exists) — choosing a brush replaces the default circle brush; ✕ clears the brush back to Circle; when an image brush is active, Hardness is disabled and additional controls appear: Spacing (stamp interval 5–100%), Angle (rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random rotation per stamp on/off)</li>
|
||||
<li id="wfm-help-imageedit-mask-5">✂ button on a mask layer row: toggles the layer as a clipping mask — when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied when saving PNG / Gallery / ComfyUI</li>
|
||||
<li id="wfm-help-imageedit-mask-6">SAM3 (Mask Editor One required): text-prompt segmentation powered by the SAM3 model; enter a description of the object to segment (e.g. "cat", "person"), set Max (maximum mask candidates: 3/6/9/12), then click Segment</li>
|
||||
<li id="wfm-help-imageedit-mask-7">SAM3 workflow: after Segment, candidate masks appear as thumbnails in the Properties pane — click to select (blue border + ✓); multiple masks can be selected; click Apply Selected (N) to apply them all; Mode (Add / Erase) controls how masks are merged into the current mask layer; a new mask layer is created automatically if the active layer is not a mask</li>
|
||||
<li id="wfm-help-imageedit-mask-8">ABR Brush Library (Mask Editor One required, Paint mode only): click Select in the MASK EDITOR ONE section to open the brush picker — browse folders on the left, click a thumbnail to apply; "Round Brush (default)" restores the circle brush; brushes are shared with Mask Editor One and stored in its brushes/ folder</li>
|
||||
<li id="wfm-help-imageedit-mask-9">Color sub-tool: click anywhere on the canvas to flood-select pixels similar in color to the clicked point — Properties pane: Mode (Add / Subtract), Tolerance (0–255, color similarity threshold), Feather % (0–100, soft edge falloff within tolerance range)</li>
|
||||
<li id="wfm-help-imageedit-mask-10">Alpha sub-tool: extract the transparency of the image layers as a mask — Properties pane: Threshold (pixels with alpha ≥ threshold become full mask; 0 = all alpha included), Invert (reverse the extracted result), Extract Alpha button applies the operation; works on a composite of all visible non-mask layers</li>
|
||||
<li id="wfm-help-imageedit-mask-11">Text sub-tool: click on the canvas to open a text stamp overlay; enter text, configure Font / Size / Bold / Italic / Align, choose Mode (Add = white stamp / Erase = cut mask), then click Stamp or press Ctrl+Enter; multi-line text supported (newline in textarea)</li>
|
||||
<li id="wfm-help-imageedit-mask-12">Vector sub-tool: click to place control points connected by a Catmull-Rom spline; click the first point again (or press Enter) to close and fill the polygon; Backspace removes the last point; Esc resets all points; Properties pane shows current point count and Mode (Add / Erase)</li>
|
||||
<li id="wfm-help-imageedit-mask-13">Shape sub-tool: drag to draw a rectangle or ellipse mask; hold Shift for a perfect square or circle; a blue dashed outline previews the shape while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-gmic-title">G'MIC Filter (Edit with G'MIC)</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-gmic-1">Applies G'MIC-Qt filter effects to the active image layer; the result is loaded back as a new layer</li>
|
||||
<li id="wfm-help-imageedit-gmic-2">Click "Edit with G'MIC" in the toolbar to launch G'MIC-Qt GUI with the current layer image</li>
|
||||
<li id="wfm-help-imageedit-gmic-3">In G'MIC-Qt: choose a filter, adjust parameters, then click OK — a "G'MIC-Qt filter output" window appears; close it to save and return the result to the Image Edit tab</li>
|
||||
<li id="wfm-help-imageedit-gmic-4">Requires G'MIC-Qt Standalone to be installed and its path configured in Settings → G'MIC-Qt Executable Path; only image-type layers are supported</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-gmic-install-title">G'MIC-Qt Installation</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-gmic-install-1">Download G'MIC-Qt Standalone from <strong>gmic.eu/download.html</strong> — choose the "G'MIC-Qt" package for your OS (Windows: gmic_qt-win64.zip)</li>
|
||||
<li id="wfm-help-imageedit-gmic-install-2">Extract the zip to any folder (e.g. C:\tools\gmic); no installer required</li>
|
||||
<li id="wfm-help-imageedit-gmic-install-3">Open Settings → G'MIC-Qt Executable Path, enter the full path to gmic_qt.exe (e.g. C:\tools\gmic\gmic_qt.exe), then click Save</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-text-title">Text Object Quality</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-14">Text layers are sized exactly to the measured text bounding box — no extra whitespace</li>
|
||||
<li id="wfm-help-imageedit-15">When a text layer is resized with the Select tool, the canvas is regenerated at the new display size so the text remains sharp (no blurring)</li>
|
||||
<li id="wfm-help-imageedit-16">Re-editing a text layer (double-click) resets it to native size; re-scale afterward if needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-export-title">Export & Navigation</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-17">Save PNG: composites all visible layers and downloads the result as a PNG file</li>
|
||||
<li id="wfm-help-imageedit-18">Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root</li>
|
||||
<li id="wfm-help-imageedit-19">Send to ComfyUI: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes</li>
|
||||
<li id="wfm-help-imageedit-20">Canvas navigation: scroll wheel to zoom, Space + drag to pan</li>
|
||||
<li id="wfm-help-imageedit-21">Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-imageedit-kbd-title">Image Edit Keyboard Shortcuts</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-22">V: Select tool</li>
|
||||
<li id="wfm-help-imageedit-23">B: Draw tool</li>
|
||||
<li id="wfm-help-imageedit-24">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-25">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-26">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-27">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-28">Space + drag: Pan canvas</li>
|
||||
<li id="wfm-help-imageedit-29">Mouse wheel: Zoom in / out</li>
|
||||
<li id="wfm-help-imageedit-30">Double-click (Select tool on text layer): Re-edit text</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1328,16 +1445,16 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3>Image Edit Tab Shortcuts</h3>
|
||||
<h3 id="wfm-help-imageedit-tabkbd-title">Image Edit Tab Shortcuts</h3>
|
||||
<ul>
|
||||
<li>V: Select tool</li>
|
||||
<li>B: Draw tool</li>
|
||||
<li>T: Text tool</li>
|
||||
<li>S: Shape tool</li>
|
||||
<li>Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li>Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li>Space + drag: Pan canvas</li>
|
||||
<li>Mouse wheel: Zoom in / out</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-1">V: Select tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-2">B: Draw tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-3">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-5">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-6">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-7">Space + drag: Pan canvas</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-8">Mouse wheel: Zoom in / out</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1353,6 +1470,7 @@
|
||||
<li id="wfm-help-trouble-5">LoRA Stack fails with "No such file or directory" error: The LoRA may not be recognized by comfyui-lora-manager (known issue). Try a different LoRA or check if the file appears in Lora Manager's own UI</li>
|
||||
<li id="wfm-help-trouble-6">Model loading error after moving or deleting models: ComfyUI caches file paths and may still reference old locations. Perform a Hard Reset from the ComfyUI menu (Queue → Hard Reset) or restart ComfyUI to clear the cache.</li>
|
||||
<li id="wfm-help-trouble-7"></li>
|
||||
<li id="wfm-help-trouble-8"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1376,6 +1494,20 @@
|
||||
<div class="wfm-help-link-desc" id="wfm-help-kofi-desc">Buy me a coffee to support development</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://github.com/ketle-man/comfyui-image-feeder" target="_blank" rel="noopener noreferrer" class="wfm-help-link wfm-help-link-github">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
<div>
|
||||
<div class="wfm-help-link-title">ComfyUI Image Feeder</div>
|
||||
<div class="wfm-help-link-desc">Complementary image input node — works with WFS Gallery Feeder mode</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://github.com/ketle-man/comfyui-mask-editor-one" target="_blank" rel="noopener noreferrer" class="wfm-help-link wfm-help-link-github">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
<div>
|
||||
<div class="wfm-help-link-title">Mask Editor One</div>
|
||||
<div class="wfm-help-link-desc">Mask editor with ABR brushes, SAM3 segmentation, BiRefNet BG removal and more</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p class="wfm-help-thanks" id="wfm-help-thanks">Thank you for using Workflow Studio!</p>
|
||||
</div>
|
||||
@@ -1391,7 +1523,10 @@
|
||||
<div class="wfm-toolbar">
|
||||
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<button id="wfm-gallery-fav-btn" class="wfm-filter-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
|
||||
<div class="wfm-search-wrap">
|
||||
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
|
||||
<button class="wfm-search-clear-btn" id="wfm-gallery-search-clear-btn" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-gallery-tag-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
@@ -1412,6 +1547,9 @@
|
||||
<span id="wfm-gallery-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-gallery-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-gallery-send-image-edit-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to Image Edit tab">Image Edit</button>
|
||||
<button id="wfm-gallery-send-genui-image-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to GenerateUI Image input">Send GenUI Image</button>
|
||||
<button id="wfm-gallery-send-cc-btn" class="wfm-btn wfm-btn-sm" title="Send selected image to ComfyUI Comic Creator's selected panel" style="display:none;">Send CC</button>
|
||||
<button id="wfm-gallery-clear-filters-btn" class="wfm-btn wfm-btn-sm" title="Clear all filters"></button>
|
||||
</div>
|
||||
<!-- 複数選択時の一括操作バー -->
|
||||
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
|
||||
@@ -1532,10 +1670,11 @@
|
||||
<button class="ie-tool-btn" data-tool="draw" title="Draw (B)">✏</button>
|
||||
<button class="ie-tool-btn" data-tool="text" title="Text (T)">T</button>
|
||||
<button class="ie-tool-btn" data-tool="shape" title="Shape (S)">□</button>
|
||||
<button class="ie-tool-btn ie-tool-placeholder" data-tool="mask" title="Mask">🎭</button>
|
||||
<button class="ie-tool-btn ie-tool-placeholder" data-tool="blur" title="Blur">≈</button>
|
||||
<button class="ie-tool-btn ie-tool-placeholder" data-tool="filter" title="Filter">★</button>
|
||||
<button class="ie-tool-btn ie-tool-placeholder" data-tool="bgremove" title="BG Remove">⬚</button>
|
||||
<button class="ie-tool-btn" data-tool="mask" title="Mask">🎭</button>
|
||||
<button class="ie-tool-btn" data-tool="blur" title="Blur">≈</button>
|
||||
<button class="ie-tool-btn" data-tool="filter" title="Filter">★</button>
|
||||
<button class="ie-tool-btn" data-tool="bgremove" title="BG Remove">⬚</button>
|
||||
<button class="ie-tool-btn" data-tool="inpaint" title="Inpaint">🩹</button>
|
||||
</div>
|
||||
|
||||
<!-- Center -->
|
||||
@@ -1581,11 +1720,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Properties Pane (mask tool etc.) -->
|
||||
<div class="ie-props-pane" id="ie-props-pane" style="display:none;">
|
||||
<div class="ie-props-header">
|
||||
<span id="ie-props-title">Properties</span>
|
||||
</div>
|
||||
<div class="ie-props-body" id="ie-props-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel (Layers) -->
|
||||
<div class="ie-right-panel">
|
||||
<div class="ie-layers-header">
|
||||
<span>Layers</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-layer-btn" title="Add layer">+</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-add-mask-btn" title="Add mask layer" style="font-weight:700;color:var(--wfm-accent,#4682e6);">M</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-del-layer-btn" title="Delete layer">−</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-up-btn" title="Move up">↑</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-layer-down-btn" title="Move down">↓</button>
|
||||
@@ -1857,11 +2005,20 @@
|
||||
<div class="wfm-ai-chat-container">
|
||||
<div class="wfm-ai-chat-messages" id="wfm-ai-chat-messages"></div>
|
||||
<div class="wfm-ai-chat-input-area">
|
||||
<div id="wfm-ai-chat-attachment" class="wfm-ai-chat-attachment" style="display:none;">
|
||||
<img id="wfm-ai-chat-attachment-thumb" class="wfm-ai-chat-attachment-thumb">
|
||||
<span id="wfm-ai-chat-attachment-label">Image attached</span>
|
||||
<button id="wfm-ai-chat-attachment-clear" class="wfm-ai-chat-attachment-clear" title="Close">✕</button>
|
||||
</div>
|
||||
<textarea id="wfm-ai-chat-input" class="wfm-ai-chat-input" placeholder="Type a message... (Enter to send, Shift+Enter for newline)"></textarea>
|
||||
<div class="wfm-ai-chat-actions">
|
||||
<button id="wfm-ai-chat-send-btn" class="wfm-btn wfm-btn-primary">Send</button>
|
||||
<button id="wfm-ai-chat-clear-btn" class="wfm-btn">Clear</button>
|
||||
<span id="wfm-ai-chat-status" class="wfm-ai-trans-status"></span>
|
||||
<label class="wfm-ai-chat-toggle-label" for="wfm-ai-chat-imggen-toggle">
|
||||
<input type="checkbox" id="wfm-ai-chat-imggen-toggle" checked>
|
||||
<span id="wfm-ai-chat-imggen-label-text">Allow image generation</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1875,6 +2032,7 @@
|
||||
<img id="wfm-ai-vlm-preview" style="display:none;max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;">
|
||||
<span id="wfm-ai-vlm-label" class="wfm-ai-vlm-label">Drop or click PNG / JPG / WebP</span>
|
||||
<input type="file" id="wfm-ai-vlm-file" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||
<button id="wfm-ai-vlm-clear" class="wfm-ai-vlm-clear" style="display:none;" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="wfm-ai-wc-inputs" id="wfm-ai-wc-inputs" style="display:none">
|
||||
<div class="wfm-ai-wc-row">
|
||||
@@ -1914,6 +2072,9 @@
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-ai-backend" value="lmstudio"> LM Studio
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
@@ -1947,6 +2108,34 @@
|
||||
<input type="text" id="wfm-ai-free-dst-lang" class="wfm-input" placeholder="e.g. German">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-chatgen-title">Chat Image Generation</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-ai-chatgen-dedicated-checkbox">
|
||||
<span id="wfm-ai-chatgen-dedicated-label">Use dedicated workflow</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row" id="wfm-ai-chatgen-dedicated-row" style="display:none;">
|
||||
<select id="wfm-ai-chatgen-dedicated-select" class="wfm-select">
|
||||
<option value="">-- select workflow --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-chatgen-i2i-title">Chat I2I Generation</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-ai-chatgen-i2i-dedicated-checkbox">
|
||||
<span id="wfm-ai-chatgen-i2i-dedicated-label">Use dedicated workflow</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row" id="wfm-ai-chatgen-i2i-dedicated-row" style="display:none;">
|
||||
<select id="wfm-ai-chatgen-i2i-dedicated-select" class="wfm-select">
|
||||
<option value="">-- select workflow --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<button id="wfm-ai-settings-save-btn" class="wfm-btn wfm-btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -408,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);
|
||||
|
||||
@@ -3003,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 {
|
||||
@@ -3184,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">
|
||||
@@ -3244,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;
|
||||
@@ -3273,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; }
|
||||
@@ -3403,7 +3419,7 @@ const setupAiHandlers = (container) => {
|
||||
if (!text) return;
|
||||
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; }
|
||||
@@ -3497,7 +3513,7 @@ const setupAiHandlers = (container) => {
|
||||
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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Reference in New Issue
Block a user