Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fbb6dd39 | ||
|
|
f858c5138a | ||
|
|
7d9c498234 | ||
|
|
d43b35dde3 | ||
|
|
b79f4e8fd0 | ||
|
|
87c53ef977 | ||
|
|
8b75d2a8d0 | ||
|
|
7aa2535964 | ||
|
|
7769de6fbb | ||
|
|
7eafe9a684 | ||
|
|
c9089c2892 | ||
|
|
9b3532704a | ||
|
|
f6f4b2c95c | ||
|
|
ac6a4f2dd6 | ||
|
|
e6c503ef75 | ||
|
|
e21f5596d8 | ||
|
|
61bf2c83da | ||
|
|
d000219b86 | ||
|
|
52ab637bd0 | ||
|
|
5e5709594b | ||
|
|
b20519c092 | ||
|
|
9d952c4b6e | ||
|
|
8248ff8ac7 | ||
|
|
7548c49910 | ||
|
|
e9f2c6171a | ||
|
|
90a7d824fb | ||
|
|
05e061d656 | ||
|
|
5afdec395c | ||
|
|
3ab4f3e36b | ||
|
|
4a23564c66 | ||
|
|
d8ffcc182e | ||
|
|
3d5bc47e20 | ||
|
|
35899bf53e | ||
|
|
f356826e9f | ||
|
|
003f3408fc | ||
|
|
54198f196a | ||
|
|
3ff76aacc6 | ||
|
|
ec8be7ee6f | ||
|
|
117fcad22a | ||
|
|
feed327c23 | ||
|
|
82ddbb340c | ||
|
|
31f02962f5 | ||
|
|
413f0d5068 | ||
|
|
bec84ac30b | ||
|
|
61e16ddce0 | ||
|
|
174a67a569 | ||
|
|
d903f33a31 | ||
|
|
afed788537 | ||
|
|
ee020cb0c0 | ||
|
|
f6942eb7fa | ||
|
|
bdfd0edb7a | ||
|
|
e1ffadbdca | ||
|
|
65ef71b4ad | ||
|
|
49019dfb05 | ||
|
|
78d9bba904 | ||
|
|
f8435ed770 | ||
|
|
b2ef333dfe | ||
|
|
dfb33e831d | ||
|
|
64c21b1224 |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# graphify: keep graph.json/graphify-out/ out of Claude Code's automatic
|
||||
# context so re-running `graphify extract` doesn't churn the prompt cache
|
||||
# on every rebuild. These are still tracked by git (see .gitignore).
|
||||
graph.json
|
||||
graphify-out/
|
||||
@@ -0,0 +1,11 @@
|
||||
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
|
||||
#
|
||||
# Unsloth Desktop requires an API key even for local access
|
||||
# (Settings -> API in the Unsloth app, shown only once when issued).
|
||||
#
|
||||
# 1. Copy this file to ".env" (same directory as this example file)
|
||||
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
|
||||
# line becomes a comment and the key will not be loaded)
|
||||
# 3. Restart ComfyUI
|
||||
|
||||
UNSLOTH_API_KEY=
|
||||
@@ -5,3 +5,12 @@ __pycache__/
|
||||
.venv
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: keep the knowledge graph itself under version control,
|
||||
# but exclude the run-by-run cost/token log, the local extraction
|
||||
# cache (both churn every rebuild and have no value to other clones),
|
||||
# and the machine-local interpreter/scan-root pointers.
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Private/unpublished planning notes — never committed to git, must not leak
|
||||
# into the published graphify-out/ knowledge graph either.
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
@@ -194,9 +194,18 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
|
||||
1. `pyproject.toml` の `version` を新バージョン番号に更新してステージングに含める
|
||||
2. `README.md` のバージョンバッジを更新(``)
|
||||
3. `README.md` の Changelog に新バージョンのエントリを追加
|
||||
4. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
5. 上記すべてを同一コミットにまとめてプッシュ
|
||||
6. `gh release create vX.Y.Z` でリリースを作成
|
||||
3. `DEVLOG.md` の先頭に変更詳細エントリを追加
|
||||
4. 上記すべてを同一コミットにまとめてプッシュ
|
||||
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
|
||||
|
||||
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
@@ -12,6 +12,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
|
||||
- Batch generation across models, samplers, prompts, and workflows
|
||||
- Image Feeder for continuous folder-based generation
|
||||
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
|
||||
|
||||
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
|
||||
|
||||
@@ -21,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -57,7 +58,8 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
|
||||
## Features
|
||||
|
||||
### Workflow Tab
|
||||
<details>
|
||||
<summary><h3>Workflow Tab</h3></summary>
|
||||
|
||||
- **Thumbnail / Table views** — switch between view modes to browse your workflow library
|
||||
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
|
||||
@@ -71,37 +73,52 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Search clear (✕)** — inline ✕ button appears inside the search box whenever text is entered; click to clear immediately
|
||||
- **Clear all filters (✕ Clear)** — rightmost toolbar button; resets search text, group filter, and badge filter (ALL) in one click
|
||||
|
||||
### Canvas Snapshot (v0.1.2)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Canvas Snapshot (v0.1.2)</h3></summary>
|
||||
|
||||
- **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)
|
||||
</details>
|
||||
|
||||
- **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
|
||||
<details>
|
||||
<summary><h3>GenerateUI Tab (v0.3.5)</h3></summary>
|
||||
|
||||
- **6-tab layout** — Input / Model / Settings / Feeder / Batch / **Lab** 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
|
||||
- **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
|
||||
- **Placeholder Image + Clear** (v0.3.97) — each LoadImage card gets a **Clear** button (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself) and a **Placeholder** button; a collapsible **Placeholder Image** section (collapsed by default) at the top of the Image sub-panel configures one shared default — **Color** mode (set Width/Height/Color, generates a solid-color PNG on the fly) or **Image** mode (drop one default image, reused as-is) — remembered across sessions; clicking a card's own Placeholder button immediately replaces just that card's image with the configured default, so multiple LoadImage cards can each be replaced individually with one click
|
||||
- **Model tab** — Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), and Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device) are always shown; **ControlNet** and **Hypernetwork** (with Strength field) are collapsed by default (v0.3.98) since they're used less often — click their header to expand; **LoRA** (Single/Stack tabs) has its own column; all selectors have a filter; Raw JSON on the right
|
||||
- **Highlight color** (v0.3.98) — the label of each model type above, plus the LoRA column header, turns a distinct color (default bright green, customizable in the Settings tab) when the loaded workflow actually contains that type of node; stays visible even while ControlNet/Hypernetwork are collapsed, so relevance is visible without expanding
|
||||
- **LoRA Stack Group toggle** (v0.3.92) — for `Lora Loader (LoraManager)` nodes, a **Use Stack Group** checkbox next to the Single/Stack tabs switches what the Stack tab shows and edits: OFF (the default whenever a workflow is loaded) displays and edits the LoRAs actually configured in the workflow's own Lora Manager node — correctly showing every LoRA it holds, not just the first one; ON switches to the LoRA group registered in the Models tab instead; toggling either direction clears the node's LoRA list first, then loads it from the newly selected source, and switching back to OFF restores exactly the list that was present when the workflow was loaded
|
||||
- **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
|
||||
- **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
|
||||
- **Create Catalog / Catalog buttons** (v0.3.95) — next to the Style dropdown. **Create Catalog** generates one preview image per Style checked in the Batch tab's Style sub-tab (using the currently loaded workflow, typically T2I), then saves each result into a chosen `ws_style_catalog` subfolder named after the Style (existing files with the same name are overwritten); a modal lets you create a new destination folder or pick an existing one — separate folders are useful for grouping catalogs by checkpoint or by purpose. **Catalog** jumps straight to the Gallery tab's Style_Catalog sub-tab to browse the results (see below)
|
||||
- **Don't keep a copy in the Output folder** (v0.3.96) — checkbox in the Create Catalog modal; each generation still writes a normal file to the Output folder first (the workflow's own Save Image node always does that), but once its copy has been saved into the catalog folder, checking this deletes that Output-folder original right away — keeps a large catalog run (e.g. every Style at once) from flooding the Output folder with near-duplicate files; if a style's catalog copy fails to save, its Output file is left alone rather than deleted
|
||||
- **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** — 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)** — 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
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and Guider-based "Advanced Sampling" workflows (`SamplerCustomAdvanced`/`SamplerCustom` + `CFGGuider`/`DualCFGGuider`/`BasicGuider` + `RandomNoise` + `KSamplerSelect` + `*Scheduler` — Flux.2 Klein, LongCat, Boogu, HiDream E1, etc.)
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
|
||||
### Feeder subtab (v0.3.5 / v0.3.42)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Feeder subtab (v0.3.5 / v0.3.42)</h3></summary>
|
||||
|
||||
Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons at the top of the left pane (persisted in `localStorage`).
|
||||
|
||||
@@ -126,9 +143,36 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **After Gen modes** — Loop (wrap at end and continue indefinitely), Increment (stop automatically when last image is reached), Fixed (always use the same index)
|
||||
- **Run / Stop controls** — left pane Run / Stop buttons manage the generation loop; After Gen combo (loop / increment / fixed), ▶ Run, and ■ Stop widgets are also available directly on the WFS_GalleryFeeder node in the ComfyUI canvas (`gallery_feeder_extension.js`)
|
||||
|
||||
### Prompt Tab
|
||||
</details>
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
|
||||
<details>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.4.2)</h3></summary>
|
||||
|
||||
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA Single/VAE) / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
|
||||
|
||||
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 3 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
|
||||
- **Per-column keyframes** — Model, Prompt, KSampler each hold an independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it — except the Model column, which resolves each of its three fields independently (see below); lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
|
||||
- **Model column — Checkpoint / LoRA Single / VAE** (v0.4.2) — the old separate Checkpoint and VAE columns merged into one Model column; a `[C][L][V]` toggle in its header picks which single field its cells summarize and its edit modal shows — switching the toggle never touches the other two fields, Save only updates whichever one is currently shown — and each button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type; the three fields carry forward independently across keyframes, so a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE back to the workflow default — it keeps whatever an earlier keyframe last set until a later one explicitly changes it (a field left blank in the edit modal shows "(inherits: ...)" instead of "(workflow default)" when there's something to inherit); "revert to #1's setting" still resets all three fields at once. LoRA support is Single only (no Stack, matching the experimental scope of this tab); if a LoRA is set but the loaded workflow has no LoRA node at all, Run warns once and skips it rather than auto-inserting one — unlike VAE (which does auto-inject a VAELoader when missing), wiring in a LoraLoader would require rewiring the Checkpoint's model/clip outputs through it
|
||||
- **Wildcards** (v0.3.93) — Prompt keyframes' Positive/Negative text supports the same A1111-style `__name__` syntax as the main GenerateUI prompt fields; each keyframe is expanded fresh right before that iteration runs, so reusing the same keyframe across iterations can still draw a different random line each time
|
||||
- **Prompt cell extras** (v0.3.88, refined in v0.3.89, v0.3.93, and v0.3.97) — **Get from GenerateUI** fills Positive/Negative from the workflow currently loaded in GenerateUI; **Get from Image** extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); **Get from Previous** (v0.3.97) copies both Positive and Negative wholesale from the keyframe immediately before this one in the same column (e.g. editing #3 copies #2's prompt; not shown on keyframe #1, which has no earlier row); **Clear** (v0.3.97) empties both fields; **Style** row — an **Apply** button merges whichever style is currently selected in GenerateUI's own top-bar Style dropdown into the Positive/Negative text below, comma-separated after any existing text (never clears it) and works regardless of that top-bar's own Style enable checkbox; a checkbox next to it tracks on/off and shows the applied style's name beside itself (not on the button) — unchecking only clears that name label, the already-merged text stays and is yours to edit/delete by hand
|
||||
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only, LoRA is never live-reflected since there's no reliable way to infer "the" active LoRA back out of a workflow node — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it — even unchanged — locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI
|
||||
- **Bypass checkbox** (v0.3.97) — available on every keyframe including #1; skips that row entirely when picking which setting applies at a given iteration, as if it didn't exist, so whatever was active before it keeps running instead; Lab-internal only, never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (mutually exclusive with Revert); the cell shows an amber "⏭ Bypassed" label, taking priority over both the Revert and live-reflect display
|
||||
- **T2I workflow support** (v0.3.90) — a **T2I workflow (no source image)** checkbox in the Image pane skips the source-image requirement and never touches a LoadImage node, so workflows with no image input (e.g. plain `EmptyLatentImage` txt2img) can also use the Model/Prompt/KSampler keyframes; the Image drop zone and "Use generated image for next" are greyed out (not cleared) while it's on; saved with the plan as `t2i_mode`
|
||||
- **Use generated image for next** — optional checkbox that chains iterations: from iteration 2 onward the previous iteration's first output image is fed back in as the I2I source instead of the original dropped image, using ComfyUI's `"name [type]"` annotated-filename reference (no re-upload needed); disabled together with the Image drop zone in T2I mode
|
||||
- **Workflow recall** (v0.3.90) — every saved plan records the filename of the workflow that was loaded in GenerateUI at save time (`workflow_filename` in the Plan JSON); after loading a plan, an **Open Workflow** button appears next to it — click to load that exact workflow file back into GenerateUI (asks for confirmation first, since it replaces whatever is currently loaded)
|
||||
- **Plan files** — Plan Save (silently overwrites the loaded plan) / Save As (always asks for a new name) / Plan Clear; plans are stored as `ws_labplan_<name>.json` in `user/default/Workflow-Studio/lab_plan/` (the `ws_labplan_` prefix is added automatically, v0.3.90 — type just the plain name when prompted), alongside an auto-generated `ws_labplan_<name>.png` index image (a 3-per-row contact sheet of up to 9 result thumbnails)
|
||||
- **Plan Load** — same drag-and-drop / click-to-browse drop zone as the Image slot; drop a plan's `.json` (read directly in the browser) or any of its index-image `.png`s — since v0.3.91 every index image carries the full plan embedded in it directly (see below), so it's read straight from the file with no server round-trip; older index images without that embedding fall back to fetching a same-named `.json` from the server
|
||||
- **Plan JSON tab** (v0.3.88) — view/edit the current plan as raw JSON (same shape saved to disk); Refresh re-reads it from the Setting tab, Apply to Setting parses your edits and writes them back into the Setting tab's columns/note/batch/chain-image/source-image/results — the same effect as loading that JSON as a plan file, without saving it first
|
||||
- **Save index image to Output on Run** (v0.3.90, on by default since v0.3.93) — checkbox in the run controls; when checked, every completed Run also builds the same contact-sheet index image used for Plan files and saves it directly into ComfyUI's own Output folder as `Lab_index_<counter>_.png` (auto-numbered like a normal generated image, right alongside the images that Run just generated); independent of Plan Save, which always writes its own copy next to the plan file regardless of this checkbox; saved with the plan as `save_index_on_run`
|
||||
- **Index images are self-contained plan files** (v0.3.91) — every index-image PNG, whether from Plan Save/Save As or from Save index image to Output on Run, carries the entire plan embedded directly in the file as a PNG `wfm_lab_plan` chunk; this is what lets the Output-folder copy above work as a Plan Load source even though it has no matching `.json` anywhere
|
||||
- **Eagle integration** — every image Lab generates is auto-saved to Eagle the same way as GenerateUI's own Generate button, if enabled in the Settings tab; the Output-folder index image above is included too (v0.3.90) once saved
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Prompt Tab</h3></summary>
|
||||
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard/Style support (right)
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
|
||||
@@ -137,21 +181,34 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
|
||||
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
|
||||
- **Wildcard/Style tab bar** (v0.3.96) — the right pane switches between the wildcard tools below and a Style manager (same pane, two modes)
|
||||
- **Wildcard input toolbar** — one-click buttons to insert `{|}`, `{n$|}`, `__|__`, `<lora::1:LBW=;>` and other wildcard syntax; wraps selected text when applicable
|
||||
- **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
|
||||
- **Style manager** (v0.3.96) — create, edit, and delete registered Styles (the same ones used by GenerateUI's Style dropdown, the Batch tab, and the Style Catalog), using the same list→editor CRUD pattern as the Wildcard file manager above; each entry has a Name, a Positive prompt (supports the `{prompt}` placeholder, or appends if omitted), and an optional Negative prompt
|
||||
- **Source-file badge & in-place editing** — each list entry shows its defining file as a small badge next to its name (e.g. `sdxl_styles_diva.json`); renaming a style keeps its position in that same file rather than moving it; a name must be unique across all styles
|
||||
- **New styles go to a dedicated file** — created via **+ New**, a style is appended to its own `custom.json` inside the style folder, so the bundled style packs are never modified directly
|
||||
- **+ Add to this file** — while editing an existing style, this button clears the form for a brand-new entry that gets appended to that *same* file instead of the default `custom.json` — the way to add a style into one of the bundled packs
|
||||
- Saving, renaming, or deleting a style immediately refreshes GenerateUI's Style dropdown, the Batch tab's checklist, and the Style Catalog's name list — no reload needed
|
||||
|
||||
### Metadata Tab (v0.3.8)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Metadata Tab (v0.3.8)</h3></summary>
|
||||
|
||||
- **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)
|
||||
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2, Mage-Flow, LongCat, Boogu, HiDream E1, FireRed, Krea-2 Turbo); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
|
||||
- **LoRA extraction** — lists all LoRA models with `strength_model / strength_clip` values
|
||||
- **Prompt extraction** — lists prompts with POS / NEG badges when positive/negative can be determined; when distinction is not possible (e.g. `SamplerCustomAdvanced`, intermediate nodes, cross-level connections), prompts are shown without a badge as plain **Text**; click any entry to view the full text below
|
||||
- **CLIP Text Encode edit+ support** (v0.3.92) — `CLIPTextEncodeEditPlus` nodes are resolved using the same RAW / EDIT / front / back combination rule as the node itself, correctly merging the linked `text1` (e.g. a Lora Manager's trigger words) with the editable `text_edit` field instead of showing only one side
|
||||
- **Prompt actions** — Copy to clipboard, **GenUI:P/N** (set GenerateUI positive/negative prompt), **Prompt:P/N** (set Prompt tab preset positive/negative)
|
||||
- **Format support** — ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 subgraph workflows), SD WebUI, SD Forge, Fooocus
|
||||
- **Format support** — ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image / Ernie Image / WAN2.2 / Mage-Flow / LongCat / Boogu / HiDream E1 / FireRed / Krea-2 Turbo subgraph workflows), SD WebUI, SD Forge, Fooocus
|
||||
- **Format note** — supported formats and covered model types are always shown in the left column
|
||||
|
||||
### Settings Tab
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Settings Tab</h3></summary>
|
||||
|
||||
- **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
|
||||
@@ -164,16 +221,23 @@ 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
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`
|
||||
- **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)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Gallery Tab (v0.3.44)</h3></summary>
|
||||
|
||||
- **Output / Style-Prompt sub-tabs** (v0.3.94) — the Gallery tab has two sub-tabs at the top: **Output** (the image browser described below) and **Style/Prompt** (a separate visual prompt-building library — see its own section below)
|
||||
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
|
||||
- **Folder tree root label** (v0.3.90) — the root entry in the folder tree is labeled simply **[root]**, no longer suffixed with the scanned folder's own directory name (which could be a misleading, environment-specific name — e.g. StabilityMatrix installs where the output folder is a symlink named `Text2Img` — even though the folder holds every kind of generated output, not just text-to-image results)
|
||||
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
|
||||
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
|
||||
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To..., Export, and Delete File from the multi-select bar
|
||||
@@ -195,9 +259,37 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)</h3></summary>
|
||||
|
||||
- **Visual prompt library** — a Gallery sub-tab for browsing reference images and building prompts from them visually, separate from the Output browser; manages a dedicated `ws_image_prompt` folder, created automatically inside the ComfyUI output directory
|
||||
- **3-column layout** — folder tree (left) | thumbnail grid (center) | prompt builder (right); selecting any folder (including a parent category folder) recursively shows every image in its subfolders too, both in the grid and in the tree's count badges
|
||||
- **Prompt builder (right pane)** — clicking a thumbnail previews it and its prompt text without adding it yet; **+ Add** appends it as a removable chip under "Selected Prompts" (**Clear All** to reset); **Save** edits and persists the prompt text for that image; **Final Prompt** combines all chips into one comma-separated, freely-editable text; **Copy** sends it to the clipboard
|
||||
- **Plain-text prompt storage** — each image's prompt lives in a `.txt` sidecar file next to it (same base name, no database) — anyone can add their own entries by dropping an image + matching `.txt` file into a subfolder; the Output Gallery's own detail panel reads and displays the same file when browsing into `ws_image_prompt`
|
||||
- **ponyxlWildcardsVault format support** — if no `.txt` sidecar exists, prompts are resolved on the fly from a `*.yaml` + `thumbnails/` (also `thumbnails_option2/`) pack placed directly under the folder, using the same key/tag structure as Navimixu's [PonyXL Wildcards Vault](https://civitai.com/models/615967/ponyxl-wildcards-vault) packs — just drop the downloaded pack folder in, no import step required
|
||||
- **Seed data importer** — `tools/import_style_prompt_seed.py` (dev tool, ships no image data) pre-processes a wildcards-vault-style source folder into portable image + `.txt` pairs under `ws_image_prompt`, for archiving a stable, portable copy instead of relying on the on-the-fly YAML fallback
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Style_Catalog Gallery subtab (v0.3.95)</h3></summary>
|
||||
|
||||
- **Visual Style picker** — a third Gallery sub-tab for picking a registered GenerateUI Style (`user/default/Workflow-Studio/style/*.json`) by its preview image instead of its name in a dropdown; manages a dedicated `ws_style_catalog` folder, created automatically inside the ComfyUI output directory; populated by GenerateUI's **Create Catalog** button (see GenerateUI Tab above)
|
||||
- **3-column layout** — folder tree (left) | thumbnail grid (center) | Positive/Negative prompt panel (right); like ImagePrompt, selecting a parent folder recursively shows every image in its subfolders
|
||||
- **Positive/Negative panel** — clicking a thumbnail reads the Positive/Negative prompt actually embedded in that generated image (reusing the Metadata tab's extraction logic — no separate metadata store), each with its own **Copy** button
|
||||
- **Select as Style button** — matches the selected image's filename (without extension) against a registered Style name and switches the GenerateUI tab's Style dropdown to it; a visual shortcut into the existing named-Style system, not a one-off apply of the embedded prompt — if the Style was renamed or deleted since the catalog image was created, a "no matching style" toast is shown instead
|
||||
- **Load in GenerateUI / Open in Metadata Tab buttons** (v0.3.96) — read the workflow embedded in the catalog image itself (a byte-identical copy of the original output image, so it carries the same PNG metadata) and either load it straight into the GenerateUI editor or open the image in the Metadata tab — no need to hunt down the original file in the Output folder, and works even on catalog images created before this feature existed
|
||||
- **Double-click to enlarge** (v0.3.96) — double-click a thumbnail in the center grid, or the selected-image preview in the right panel, for the same full-size lightbox view as the Output Gallery
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Nodes Tab (v0.1.7)</h3></summary>
|
||||
|
||||
- **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
|
||||
@@ -209,7 +301,10 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Models Tab (v0.2.3)</h3></summary>
|
||||
|
||||
- **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**
|
||||
@@ -234,7 +329,10 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Tagger Tab (v0.3.38)</h3></summary>
|
||||
|
||||
- **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)
|
||||
@@ -246,7 +344,10 @@ 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.65)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Image Edit Tab (v0.3.65)</h3></summary>
|
||||
|
||||
- **Layer-based image editor** — compose images with multiple layers (Image, Text, Draw, Mask types) and export the composite as PNG
|
||||
- **Loading images** — drag & drop onto the canvas, Upload button, or send from the Gallery tab via the **Image Edit** toolbar button; first image becomes Layer 1 (auto-locked), subsequent images are added as new layers scaled to fit the canvas
|
||||
@@ -256,7 +357,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to Workflow**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
@@ -264,23 +365,40 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **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)
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Save to Input** (uploads to ComfyUI input folder for use in Load Image nodes); **Send to Workflow** (v0.4.0) — uploads the same way, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable
|
||||
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), Delete (remove selected layer when 2+ exist)
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>AI TOOL Tab (v0.3.14)</h3></summary>
|
||||
|
||||
- **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, Lemonade, or Unsloth; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Reliability fix** (v0.3.98) — switched from a raw single-string prompt to a system-role chat request (Ollama `/api/chat`, LM Studio/Lemonade `/v1/chat/completions` with a `system` message) so small local models follow the "translation only" instruction more reliably; output is cleaned of common LLM preambles/quotes, and a warning toast appears if the model appears to have echoed the source text unchanged instead of translating it
|
||||
- **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, Lemonade, and Unsloth 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
|
||||
- **Chat pane — Skills** (v0.3.79) — select a `.md` skill file from a dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); a pencil button opens an in-pane manager (list + inline editor, same pattern as the Prompt tab's wildcard file manager) to create, edit, or delete skill files stored in `user/default/Workflow-Studio/ai_skills/`; a file's `---`-delimited frontmatter (`name`/`description`) is shown in the dropdown; two starter skills are bundled — **SVG Icon Generator** (explicitly decomposes the subject into parts before drawing, which markedly improves recognizability from small local models) and **Skill Creator** (interviews you conversationally and drafts a new skill as a ```skill code block; a "Save as new skill" button appears under such replies and opens the manager pre-filled with the draft)
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade / Unsloth), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API always requires an API key, even for local access; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings pane — Generation** (v0.3.98) — **Thinking mode** toggle (off by default; sends Ollama's `think` flag, and regardless of backend, any `<think>...</think>` block is stripped from the output when off so reasoning models don't clutter Translation/Chat/TOOLS results) and **Max tokens** (Ollama: `options.num_predict`; LM Studio/Lemonade/Unsloth: `max_tokens`; `0` = unlimited)
|
||||
- **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 directly from the browser (`/v1/chat/completions`, `/v1/models`); Unsloth via the same OpenAI-compatible API but relayed through a server-side proxy that attaches the `Authorization: Bearer` header; VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade/Unsloth)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
- **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)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)</h3></summary>
|
||||
|
||||
- **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
|
||||
@@ -290,7 +408,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, Lemonade, or Unsloth; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **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
|
||||
@@ -303,13 +421,18 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
- **Search** — search within each sub-tab to quickly find items
|
||||
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
|
||||
|
||||
### Help & Support Tab (v0.1.3)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Help & Support Tab (v0.1.3)</h3></summary>
|
||||
|
||||
- **Sidebar navigation** — 2-column layout: left sidebar (15 topics) + right content pane; click any topic to switch the displayed content
|
||||
- **Support** — fixed at the bottom of the sidebar; shows GitHub, Ko-fi, **ComfyUI Image Feeder**, and **Mask Editor One** repository links in the right pane
|
||||
- **Feature list** — overview of all features organized by tab
|
||||
- **Keyboard Shortcuts** and **Troubleshooting** sections included
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
@@ -381,6 +504,8 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[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
|
||||
|
||||
@@ -396,597 +521,6 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.71
|
||||
|
||||
- **Image Edit — Inpaint tool (🩹)** — new tool composites the canvas (all non-mask layers) plus the active mask layer, uploads to an inpaint-capable LoadImage node (or a Mask Editor One node) in the GenerateUI workflow, applies Positive/Negative prompts, Grow Mask By, and Denoise, then triggers generation — all without leaving the Image Edit tab; the result is shown in the Properties pane
|
||||
- **Image Edit — Inpaint: dedicated workflow** — "Use dedicated workflow" checkbox lets you pick a saved workflow to run the inpaint against instead of the one currently loaded in GenerateUI, without disturbing what's shown there
|
||||
- **Image Edit — Inpaint: Mask Editor One support** — workflows built around the `comfyui-mask-editor-one` custom node (no LoadImage) are also supported; image/mask are pushed via its node_id-keyed server cache and `layer_data` widget
|
||||
- **GenerateUI — Input/Image tab mask slots** — LoadImage nodes whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) now show an additional Mask drop zone; image + mask are composited into a single RGBA upload matching ComfyUI's native alpha→MASK extraction
|
||||
- New example workflows: `ws_inpaint_basic.json`, `ws_inpaint_mask_editor_one.json`
|
||||
|
||||
### v0.3.70
|
||||
|
||||
- **Workflow AI summary — Ollama 404 fix** — the summarize button in the workflow detail modal now uses the Ollama URL/model saved in Settings → AI Assistant Settings (previously it silently fell back to a hardcoded default model tag that didn't match locally installed models, causing a 404)
|
||||
- **Image Edit — New button auto-adds Layer 1** — creating a blank canvas via the New button now automatically adds an empty "Layer 1", matching the existing image-load behavior
|
||||
- **Image Edit — mask overlay shown only for the active mask layer** — previously every visible mask layer's overlay was rendered regardless of selection; now only the currently active mask layer shows its overlay, other mask layers stay hidden until selected
|
||||
- **GenerateUI — Revert button for Positive/Negative Prompt** — a ↺ button next to each Apply button reverts the textarea to the node's currently applied value
|
||||
- **GenerateUI — Ctrl+↑/↓ prompt emphasis weight editing** — A1111 / native ComfyUI CLIP Text Encode style: select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05; parentheses are removed automatically when the weight returns to 1.0
|
||||
|
||||
### v0.3.69
|
||||
|
||||
- **Gallery — Send CC button** — new toolbar button, shown only when the Gallery tab is embedded as an iframe inside ComfyUI Comic Creator (a separate custom node); sends the selected image directly into Comic Creator's currently selected panel/overlay via its existing `insertImageFromUrl(url)` helper (no changes required on the Comic Creator side)
|
||||
- **Lora batch — validation fix** — fixed a `Prompt outputs failed validation` error where LoRA files that actually existed were rejected by the standard `LoraLoader` node; the root cause was a path-separator mismatch (ComfyUI on Windows returns `\`-separated enum values while WFS tracked LoRAs internally with `/`); a shared `resolveLoraName()` helper now converts to ComfyUI's actual notation before applying, used consistently by Lora Single apply, Stack apply, the Models tab GenUI Model button, and Lora batch generation
|
||||
- **Lora batch — pre-flight check** — before running a batch on a standard `LoraLoader` workflow, selected LoRAs are checked against ComfyUI's current model list; if any are not yet recognized, a warning toast suggests a Refresh or ComfyUI restart (skipped for Lora Manager–based workflows, which have no enum constraint)
|
||||
- **Lora batch — automatic prompt sync** — for each LoRA applied during a batch, its syntax (`<lora:name:strength_model:strength_clip>`) and CivitAI trained words are automatically appended to the Positive prompt, rebuilt from the original prompt each time (not accumulated); the prompt is automatically restored to its pre-batch state when the batch completes, is stopped, or errors
|
||||
- **Models tab — Close button in detail modal** — added a Close button at the right end of the Save / GenUI PP·NP / Delete button row in the model detail modal (all sub-tabs), reducing mouse travel to the top-right ✕ during repeated batch edits
|
||||
- **Help** — new help entry (`helpGen19`) added in EN / JA / ZH describing the Lora batch prompt sync, restore, and pre-flight check behavior
|
||||
|
||||
### v0.3.68
|
||||
|
||||
- **Image Edit — MASK EDITOR ONE section always visible** — the ABR brush section in the Draw tool's (`MASK EDITOR ONE (COLOR)`) and Mask Paint mode's (`MASK EDITOR ONE`) Properties pane is now always shown instead of being hidden when no brushes are imported; the header text turns green when at least one ABR brush is available and gray when none are imported yet; the Select button is disabled until a brush exists
|
||||
- **Help — MASK EDITOR ONE status** — updated `helpImageEdit5` (Draw tool) and `helpImageEditMask4` (Mask Paint mode) in EN / JA / ZH to describe the always-visible section and its color-coded availability state
|
||||
|
||||
### v0.3.67
|
||||
|
||||
- **GenerateUI — Alt+Click Apply & Generate** — Alt+Click any Apply button to apply the parameter and immediately queue a generation in one step; applies to Positive Prompt Apply (Input tab), all model Apply buttons (Model tab: Checkpoint / VAE / Diffusion Model / ControlNet / Hypernetwork), and KSampler Apply (Settings tab); only triggers generation when the target node exists in the loaded workflow (no action on missing nodes); each Apply button shows a tooltip `Apply (Alt+Click: Apply & Generate)` on hover
|
||||
- **Help — Alt+Click Apply & Generate** — new help entry (`helpGen18`) added in EN / JA / ZH
|
||||
|
||||
### v0.3.66
|
||||
|
||||
- **Image Edit — Mask Tool: 5 new sub-tools** — Paint / Color / Alpha / Text / Vector / Shape / SAM3 (7 sub-tools total); Color, Alpha, Text, Vector, and Shape work without Mask Editor One
|
||||
- **Color** — click the canvas to flood-select pixels similar in color to the clicked point; Properties pane: Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100)
|
||||
- **Alpha** — extract the active layer's alpha channel as a mask; Properties pane: Threshold, Invert checkbox, Extract Alpha button
|
||||
- **Text** — click to open a stamp overlay; enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), confirm with Stamp or Ctrl+Enter; multi-line text supported
|
||||
- **Vector** — click to add Catmull-Rom spline control points; Enter (or click the first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase)
|
||||
- **Shape** — drag to draw a rectangle or ellipse; hold Shift for a perfect square/circle; blue dashed outline preview while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)
|
||||
- **Help — Support page** — added **ComfyUI Image Feeder** and **Mask Editor One** GitHub repository links to the Support card
|
||||
|
||||
### v0.3.65
|
||||
|
||||
- **Image Edit — Mask Editor One integration fixes** — `store_image` API field mismatch (`image_b64` → `bg_image_b64`) corrected so BiRefNet and SAM3 no longer report "no image available"; `server.py` updated to accept both field names for backward compatibility
|
||||
- **Image Edit — top bar icon persistence** — WFS / Snapshot / Node Sets SVG icons in the ComfyUI action bar disappeared when the Properties Panel was opened (Vue re-render reset custom `innerHTML` to `<i>`); fixed with a `MutationObserver` that re-applies SVG icons whenever a reset is detected
|
||||
- **Image Edit — Mask Tool: ABR brush library** (requires Mask Editor One) — Select button in the Properties pane opens a folder-tree + thumbnail-grid brush picker; stamp brushes are loaded from Mask Editor One's `/mask_editor/brushes/` folder; ✕ clears back to the default circle brush
|
||||
- **Image Edit — Mask Tool: ABR brush controls** — when an image brush is active, Hardness is disabled and new controls appear in the Properties pane: Spacing (stamp interval 5–100%), Angle (fixed rotation 0–359°), Sz Jitter (random size reduction per stamp 0–100%), Rot. Jitter (random angle per stamp on/off); size jitter clamped to minimum 1 px to prevent zero-dimension canvas errors
|
||||
- **Image Edit — Mask brush cursor** — brush size circle is now hidden outside the canvas; only shown when the pointer is within the canvas bounds (window-level mousemove no longer shows the cursor overlay outside)
|
||||
|
||||
### v0.3.64
|
||||
|
||||
- **Image Edit — Mask layer Add/Subtract** — A/S toggle button on each mask layer row; Add (green, default) composites the mask normally; Subtract (red) cuts the mask area from the layer below; multiple mask layers are composited back-to-front matching Mask Editor One's CanvasCompositor logic
|
||||
- **Image Edit — BiRefNet BG Remove** (requires Mask Editor One + `birefnet.safetensors` in `ComfyUI/models/background_removal/`) — BiRefNet model now fully functional via Mask Editor One's Python backend; grayscale mask returned as RGBA PNG and applied as a new layer or in-place per the New Layer toggle
|
||||
- **Image Edit — SAM3 segmentation** (requires Mask Editor One) — new SAM3 sub-tool in the Mask tool options bar; enter a text prompt, set Max candidates (3/6/9/12), click Segment; candidate mask thumbnails shown in the Properties pane; click to select (multi-select), Apply Selected (N) applies all chosen masks with Add or Erase mode; new mask layer auto-created if active layer is not a mask
|
||||
|
||||
### v0.3.63
|
||||
|
||||
- **Nodes tab — multi-select** — Ctrl+click to toggle individual nodes, Shift+click for range selection; bulk action bar appears with add to group / remove from group / create & add group / bulk favorite / unfavorite; works in both Card and Table view modes
|
||||
- **All tabs — search clear (✕)** — inline ✕ overlay button inside each search box (Workflow / Nodes / Models / Gallery); shown only when text is entered, click to clear and re-filter immediately
|
||||
- **All tabs — clear all filters button** — new ✕ Clear button at the rightmost end of each tab's toolbar; resets all active filters (search, group, tag, folder, favorites, badge) in one click; Gallery sort order is preserved
|
||||
|
||||
### v0.3.62
|
||||
|
||||
- **Gallery tab — Send GenUI Image button** — new toolbar button (next to Image Edit); uploads the selected image to ComfyUI and sets it as the input for the first LoadImage node in the GenerateUI tab; automatically switches to GenerateUI → Input → Image; `comfyEditor.applyImageToSlot(file, slotIndex)` added to `comfyui-editor.js` for reusable image-slot injection
|
||||
- **Bug fix: tagger_settings.json missing from data export** — `tagger_settings.json` was absent from `_DATA_FILES` in `settings_routes.py`; Tagger model directory and threshold settings were silently excluded from Settings → Data Management export/import; fixed by adding the file to the export list
|
||||
- **Help — Send GenUI Image** — new help card (`helpGallery17`) added in EN/JA/ZH; registered in `helpIdMap`
|
||||
- **Screenshots — Tagger and Image Edit tabs** — `docs/12_Tagger.png` and `docs/13_ImageEdit.png` added to the README screenshots section
|
||||
|
||||
### v0.3.61
|
||||
|
||||
- **Image Edit — Draw / Mask brush cursor** — cursor replaced with a size-accurate circle overlay matching the current brush size; the circle scales correctly with canvas zoom so the visible area matches exactly what will be painted
|
||||
- **Image Edit — Draw / Mask out-of-canvas strokes** — strokes can now start from the canvas margin area (outside the image boundary) and continue past the edge without interruption; `window`-level `mousemove` / `mouseup` tracking keeps the stroke alive when the pointer leaves the canvas element
|
||||
- **G'MIC — argument order fix** — corrected `gmic_qt.exe` launch arguments to `-o <output> <input>` (the only order G'MIC-Qt Standalone accepts); the previous order caused the GUI to exit immediately without opening
|
||||
- **G'MIC — settings path placeholder** — replaced the hard-coded user-specific default path with a generic `C:\path\to\gmic_qt.exe` placeholder; settings load without a pre-filled path for new users
|
||||
- **G'MIC — security: path traversal fix** — `POST /api/wfm/gmic/result` now validates that `result_path` is inside the `gmic_temp` directory before reading the file; requests for paths outside that directory are rejected with 403
|
||||
- **Help — G'MIC Filter section** — added two new help cards (G'MIC Filter usage and G'MIC-Qt Installation) with full i18n support (English / Japanese / Chinese)
|
||||
|
||||
### v0.3.60
|
||||
|
||||
- **Image Edit — Filter tool (G'MIC-Qt integration)** — the previously inactive ★ Filter tool now launches G'MIC-Qt GUI with the active layer image; select a filter and click OK, then close the "G'MIC-Qt filter output" window to apply the result back as the active layer; polling-based job system with progress display, Cancel button, and Undo support; requires G'MIC-Qt Standalone (`gmic_qt.exe`) configured in Settings
|
||||
- **Settings — G'MIC-Qt Executable Path** — new collapsible section in Settings for entering and saving the path to `gmic_qt.exe`
|
||||
|
||||
### v0.3.59
|
||||
|
||||
- **Image Edit — Mask Tool** — new Mask tool (🎭) in the Image Edit sidebar; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the layer list also switches to the Mask tool; **Tool Options bar**: Paint button, Invert checkbox (inverts overlay preview), Overlay color picker (default red), Blur slider (0–50 px) for overlay edge softening; **Properties pane** (left of the Layers panel, shown when Mask tool is active): Size (1–200 px), Hardness (0–100%), Mode — **Add** / **Erase** toggle buttons (active mode highlighted in blue)
|
||||
- **Image Edit — Clipping mask (✂ button)** — each mask layer row shows a ✂ toggle; when enabled (blue), the mask clips the layer directly below it so only the painted area is visible; clipping is applied to PNG save / Save to Gallery / Send to ComfyUI exports
|
||||
- **Image Edit — Draw & Mask paint bugfix** — Draw and Mask tools now paint directly onto `layer.canvas`; all other layers remain fully visible during brush strokes (previously the composite view was replaced by the active layer only while painting)
|
||||
- **Image Edit — Layer panel width** — increased from 190 px to 220 px to accommodate the additional ✂ button on mask layer rows
|
||||
- **Help — Mask Tool section** — added Mask Tool card with full i18n support (English / Japanese / Chinese); updated Tools list, layer type icon list, and Layer panel "M button" description
|
||||
|
||||
### v0.3.58
|
||||
|
||||
- **GenerateUI Model tab — Diffusion Model GGUF support** — `LoaderGGUF` and `LoaderGGUFAdvanced` nodes are now recognized alongside `UNETLoader` / `UnetLoaderGGUF`; the correct input key (`gguf_name` vs `unet_name`) is selected automatically on Apply; the Models tab "GenUI Model" button also applies correctly to `LoaderGGUF` workflows
|
||||
- **GenerateUI Model tab — Text Encoder expanded (CLIPLoader / DualCLIPLoader / GGUF variants)** — the Text Encoder section now renders a node-type-aware UI: **single CLIP** (`CLIPLoader` / `ClipLoaderGGUF`) shows one clip selector + **type** dropdown + **device** (GGUF only); **Dual CLIP** (`DualCLIPLoader` / `DualClipLoaderGGUF`) shows two clip selectors (clip_name1 / clip_name2) + **type** dropdown + **device** (GGUF only); type options are fetched dynamically from `/object_info`; filter applies to the first selector only
|
||||
|
||||
### v0.3.57
|
||||
|
||||
- **Image Edit — Blur tool** — new Blur tool (≈) in the Image Edit sidebar; **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: toggle to enter rect-draw mode then drag to select a region — blur or mosaic is applied on mouse release; Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo (Ctrl+Z)
|
||||
- **Image Edit — BG Remove tool** — new BG Remove tool (⬚); **Lightweight (@imgly)**: background removal running entirely in the browser via CDN (~40 MB model cached after first load); **BiRefNet**: stub wired to a future Python backend endpoint (coming soon); **New Layer** checkbox (default on) adds the result as a new layer above the original — uncheck to replace the active layer in-place; supports Undo
|
||||
- **Image Edit — sidebar icons** — removed `ie-tool-placeholder` dimming from Blur and BG Remove buttons; both now display at full brightness matching other active tools
|
||||
- **Help — Blur / BG Remove sections** — added two new help cards (Blur Tool and BG Remove Tool) with full i18n support (English / Japanese / Chinese); Tools list updated with Blur and BG Remove overview items
|
||||
|
||||
### v0.3.56
|
||||
|
||||
- **GenerateUI — Style selector** — new checkbox and dropdown next to the Reset Workflow button; when enabled, a Fooocus-style JSON style is applied to positive and negative prompts at generation time; style files (`*.json`) are read from `user/default/Workflow-Studio/style/`; multiple files are merged into a single list sorted by filename; the style's `{prompt}` placeholder is replaced with the original prompt text, otherwise the style text is appended; `negative_prompt` is appended to the existing negative prompt; styles are applied to a per-generation workflow copy and never modify the loaded workflow
|
||||
- **Batch tab — Style batch** — new **Style** tab in the Batch left pane lists all styles loaded from the style directory (flat list with All / None buttons); select any combination, enable the **Style** column in Batch Queue, then Generate to run each selected style sequentially; Batch Queue expanded from 6 to 7 columns (Style added as the rightmost column)
|
||||
|
||||
### v0.3.55
|
||||
|
||||
- **Group feature — fix data loss on Windows (backslash/slash mismatch)** — on Windows, ComfyUI returns model paths with backslashes (`sub\model.safetensors`) while the backend normalized to forward slashes; this caused all subdir group members to be flagged as missing and deleted on save; fixed by normalizing both stored and scanned paths to forward slashes in `get_model_groups` and `save_model_groups`
|
||||
- **Group feature — network drive protection** — if no model directories are accessible (e.g. external drive disconnected), the group cleanup step is now skipped entirely to prevent wiping all group memberships
|
||||
- **Group feature — scan cache** — `_scan_model_names` now caches results for 30 seconds to eliminate redundant full directory scans on startup (caused high CPU load / crash on large model sets)
|
||||
- **Group feature — RESERVED_GROUPS protection** — `bulkRemoveFromGroup` and `renderPmGroups` now prevent Batch/Stack reserved group keys from being deleted even when all members are removed
|
||||
- **Group feature — stale groupFilter reset** — after deleting a group, `state.groupFilter` is automatically cleared if it pointed to the now-deleted group (previously showed 0 models)
|
||||
- **Group feature — toggleGroupEnable partial failure** — only models listed in `data.ok` are updated client-side, preventing state divergence when some enable/disable calls fail
|
||||
- **GenerateUI batch groups — restore Batch/Stack visibility** — reverts an incorrect filter that hid Batch and Stack groups from the batch group selection UI; these groups are now selectable for batch execution as intended
|
||||
- **GenerateUI LoRA groups — path normalization** — backslash-to-slash normalization added after fetching LoRA groups from the API, matching the Checkpoint fix
|
||||
- **Batch complete toast — i18n** — replaced hardcoded English toast with `t("batchComplete", ...)` supporting all 3 languages
|
||||
- **Prompt tab migration — fix negText loss** — `migrateLocalStoragePresets` now correctly passes `negText` when calling `apiCreatePreset`, preventing negative prompts from being silently dropped during localStorage→API migration
|
||||
- **Help tab — Image Edit section i18n** — the Image Edit help page was hardcoded in English with no language switching support; all 6 sections (30 items + 7 headings) now have i18n IDs wired to English / Japanese / Chinese translations
|
||||
|
||||
### v0.3.54
|
||||
|
||||
- **Image Edit — Shape tool (S)** — new Shape tool added to the Image Edit toolbar; draw Rect, Ellipse, Line, and FreeLine shapes by dragging; each committed shape becomes an independent draw layer that can be undone in a single step; Tool Options: shape type selector, Rounded toggle (Rect/Ellipse only), Fill color + None toggle (hidden for Line/FreeLine), Stroke color + width (Stroke None hidden for Line/FreeLine where stroke is always active), Opacity slider, Undo button; blue dashed outline previews the shape during drag; keyboard shortcut **S** activates the tool
|
||||
- **Models tab — fix B/S overlay buttons not highlighting** — the Batch (B) and Stack (S) card overlay buttons failed to turn yellow after clicking because `e.currentTarget` is nulled by the browser after an `async` event handler yields at `await`; fixed by capturing `const btn = e.currentTarget` before the await in both handlers
|
||||
|
||||
### v0.3.53
|
||||
|
||||
- **Image Edit Tab** — new layer-based image editing tab with Select / Draw / Text tools, multi-layer compositing, canvas zoom & pan, and Undo/Redo; text layers are sized to exact bounding-box dimensions and regenerated at display resolution on resize to prevent blurring; double-click a text layer to re-edit its content; Gallery tab gains an "Image Edit" toolbar button to send the selected image directly to the editor
|
||||
- **Image Edit — Layer lock** — lock icon (🔒/🔓) on each layer row; locked layers display an orange bounding box and ignore all Select-tool transforms; Layer 1 is auto-locked when the first image is loaded to prevent accidental moves
|
||||
- **Image Edit — Save to Gallery** — "Save to Gallery" button (next to Save PNG) saves the composite image to the Gallery root folder; filename defaults to `wfs-image-YYYYMMDDHHmmss`; backed by new `POST /wfm/gallery/image/save` API endpoint
|
||||
- **Help tab — Image Edit page** — new dedicated help page covering tools, layer panel, text quality, export options, and keyboard shortcuts
|
||||
|
||||
### v0.3.52
|
||||
|
||||
- **GenerateUI Model tab — fix LoRA selection reset when switching subtabs** — applying a LoRA via "GenUI Model" button and then switching to another subTab (Prompt/Image/Settings) and back to Model caused the LoRA selector, syntax display, and trigger words to revert to the original workflow values; fixed by reading `currentVal` directly from `currentWorkflow` instead of the stale `currentAnalysis`, and by saving/restoring the Single tab's syntax and trigger words across re-renders
|
||||
|
||||
### v0.3.51
|
||||
|
||||
- **Models tab — Embedding type: GenUI PP / GenUI NP buttons** — Embedding models now show two buttons ("GenUI PP" and "GenUI NP") in the side panel nav, detail side panel, and detail modal (replacing the single "GenUI Model" button); clicking either appends `(embedding:Name:1.0)` to the Positive or Negative prompt of the loaded workflow
|
||||
- **GenerateUI Prompt tab — Embeddings selector** — new section at the bottom of the Prompt tab: Filter input, model select dropdown, Weight input (default 1.0), and Paste button; Paste inserts `(embedding:Stem:weight)` at the cursor position of the last focused textarea (Positive or Negative); focus state is tracked on click/keyup/blur so the button works even after clicking away
|
||||
- **GenerateUI Model tab — Hypernetwork support** — Hypernetwork section added with model selector (filter) and Strength field; applies to `HypernetworkLoader` nodes in the workflow; GenUI Model button in Models tab also triggers Hypernetwork apply
|
||||
- **Models tab — fix Embedding and Hypernetwork models not shown** — `_fetchModelList` now handles the ComfyUI V3 node format (`options` key) in addition to V1/V2 formats; `fetchEmbeddings` switched from the `/embeddings` ComfyUI endpoint to the WFS backend API (`/api/wfm/models/files?type=embedding`) to avoid SPA URL routing conflicts
|
||||
- **Models tab — GenUI Model button in side panel nav** — "GenUI Model" button added to the tab navigation bar (Info / Group / CivitAI) of the side panel; appears for supported model types (Checkpoint, LoRA, VAE, etc.) and hides for unsupported types; Embedding type shows PP/NP nav buttons instead
|
||||
|
||||
### v0.3.50
|
||||
|
||||
- **Gallery tab — fix prompt search causing ComfyUI hang under large folders** — the v0.3.49 on-demand metadata read ran synchronously on the aiohttp event loop, blocking ComfyUI when folders contained many uncached images; fixed by: (1) removing on-demand reads from `list_images()`; (2) wrapping `list_images()` in `asyncio.to_thread()` to keep the event loop free; (3) adding a background indexer (`start_background_index`) that automatically indexes uncached images after a folder is loaded — processes 10 images per batch with a 50 ms sleep between batches; cancellable on folder switch
|
||||
|
||||
### v0.3.49
|
||||
|
||||
- **GenerateUI tab — A1111-style SPA-side wildcard expansion** — when prompt text in non-ImpactWildcard nodes (e.g. CLIPTextEncode) contains `__name__` syntax, the SPA expands it at generation time by randomly picking a line from the matching file in the WFS wildcard folder; nested wildcards are re-expanded up to 5 passes; unresolved wildcards are passed through unchanged; ImpactWildcardEncode/Processor nodes are skipped (handled server-side by Impact Pack); works in both single and batch generation
|
||||
- **Gallery tab — fix Export button missing label** — the bulk action bar's Export button had no text label because the `textContent` assignment was missing from `updateUITexts()`; fixed by adding the `galleryBulkExport` i18n key assignment alongside the existing Move/Delete entries
|
||||
- **Gallery tab — fix prompt search not matching images with no cached index** — `prompt_cache` was only built when an image was opened in the detail panel; A1111 images (or any images never opened) were invisible to prompt search; now when search is active and `prompt_cache` is empty, the image file is read on-demand and the cache is populated and persisted for future searches
|
||||
|
||||
### v0.3.48
|
||||
|
||||
- **Gallery tab — single image download** — hover over a detail-panel preview image to reveal a download overlay (⬇ icon); click to download the original file with its original filename preserved
|
||||
- **Gallery tab — bulk export to ZIP** — multiple selected images can be exported as a ZIP archive via the "Export" button in the bulk action bar; filename: `gallery_export_<timestamp>.zip`; backend: `POST /wfm/gallery/images/export-zip` with path-traversal protection
|
||||
|
||||
### v0.3.47
|
||||
|
||||
- **Models tab — fix stale group state after file move** — when models were moved to a subdirectory via "Move To...", `state.modelGroups` in the JS client was not updated to reflect the new model paths; the group filter view would lose the moved models immediately after the operation. Now the client-side group membership is updated in sync with the server-side rename (mirrors the existing Python `move_models` logic). Generate tab Batch groups are unaffected as they always fetch fresh from the server.
|
||||
|
||||
### v0.3.46
|
||||
|
||||
- **Prompt tab AI Assistant — LM Studio support** — switched from Python proxy (`/api/wfm/ollama/*`) to direct browser-to-backend API calls (same pattern as the AI TOOL tab); supports Ollama (`/api/chat`) and LM Studio (`/v1/chat/completions`); image attachments are automatically converted to the correct format per backend
|
||||
- **Settings tab — AI Assistant Settings (Prompt Tab)** — renamed from "Ollama Settings"; added backend selector (Ollama / LM Studio), URL input with per-backend placeholder, model select with Refresh, and Test button; settings saved to localStorage `wfm_prompt_ai_settings` (independent of AI TOOL tab settings)
|
||||
- **Auto-refresh on backend change** — if the backend is switched in Settings tab while Prompt tab is already open, `sendMessage()` now detects the mismatch and automatically re-fetches the model list before sending; model status shows the active backend name (`[ollama]` / `[lmstudio]`)
|
||||
- **Help text updated** (EN/JA/ZH) — Prompt tab, Settings tab, and Troubleshooting sections updated to reflect Ollama / LM Studio dual-backend support
|
||||
|
||||
### v0.3.45
|
||||
|
||||
- **Group auto-cleanup — Models tab** — stale group entries (models that no longer exist on disk) are automatically removed when the Models tab is loaded or the model type is switched; models moved within the app via "Move To..." also update their group membership immediately
|
||||
- **Group auto-cleanup — Gallery tab (folder view)** — metadata entries for missing files are cleaned up each time a folder is scanned (`list_images`), synchronized with the 60s TTL + folder-mtime cache
|
||||
- **Group auto-cleanup — Gallery tab (group image list)** — `list_images_in_group` (used by the Feeder tab Gallery mode) now filters out non-existent paths and removes them from the group in a single write operation
|
||||
- **Gallery tab — Shift+click range selection** — Shift+click selects all images between the last clicked item (anchor) and the current item; works in both thumbnail and table views; anchor is updated on Ctrl+click and regular click; resets on folder change, image reload, and Deselect All; help text updated (EN/JA/ZH)
|
||||
|
||||
### v0.3.44
|
||||
|
||||
- **Gallery tab — server-side thumbnail generation** — `GET /wfm/gallery/image/thumb?path=...&w=256` endpoint generates 256px JPEG thumbnails via Pillow and caches them to `data/thumb_cache/` keyed by `md5(path:mtime:width)`; GIF served as-is to preserve animation; falls back to the original file if Pillow is unavailable; thumbnail view and table view both switched from `/image/serve` to `/image/thumb`
|
||||
- **Gallery tab — infinite-scroll paging** — thumbnail view renders 50 images initially; an IntersectionObserver sentinel (rootMargin 300px) appends the next page on scroll; sentinel is disconnected and removed when all images are rendered; paging resets on every `renderImages()` call (folder change, filter, sort)
|
||||
- **Gallery tab — bulk API endpoints** — `POST /wfm/gallery/bulk/favorite` and `POST /wfm/gallery/bulk/group` handle favorite set and group add/remove for any number of images in a single request; replaced N-parallel individual fetches in `bulkSetFavorite()` and `bulkAddToGroup()`
|
||||
- **Gallery tab — image compare mode** — Ctrl+click 2–4 images, then click "Compare" in the Bulk Bar to open a side-by-side lightbox; CSS grid adapts to the image count via `--compare-cols` custom property; close button is `position:fixed` at the top-right corner to avoid `overflow:auto` clipping
|
||||
- **Gallery tab — "Remove from Group" bulk button** — new button in the Bulk Bar (right of "Add to Group"); uses the same group selector dropdown; calls `POST /wfm/gallery/bulk/group` with `action:"remove"`
|
||||
- **Gallery tab — bulk bar UI labels** — dropdown placeholder "Add to Group..." → "Select Group"; "Add" button → "Add to Group"; "Delete" button → "Delete File" (distinguishes file deletion from group removal)
|
||||
- **Gallery tab — prompt search** — text search now covers A1111 `parameters` text embedded in PNG/JPEG metadata; prompt text is extracted on first detail-panel open and stored as `prompt_cache` in `metadata.json`; subsequent `list_images` searches include `prompt_cache` without re-reading image files
|
||||
- **Help tab — Gallery mode i18n** — Feeder subtab Gallery mode section (h4, description, 6 bullet points) now translated to Japanese and Chinese via `i18n.js`
|
||||
- **Help tab — font size ×1.5** — nav items, headings, body text, link cards, and support card text all scaled up in `main.css`
|
||||
- **Help tab — search box** — live search input above the nav sidebar filters matching pages and auto-clicks the first result; placeholder text localized (EN/JA/ZH)
|
||||
|
||||
### v0.3.43
|
||||
|
||||
- **Send to Canvas — direct canvas load via `window.opener`** — clicking "Send to Canvas" (Workflow tab) or "Copy & Send Canvas" (Gallery tab) now sends the workflow directly to the ComfyUI canvas without requiring a title drag; uses `window.opener.wfmReceiveWorkflow()` registered by `node_sets_menu.js` via `app.handleFile()`; both UI and API formats are supported; fallback to localStorage + title drag when `window.opener` is unavailable (e.g. opened from a bookmark), UI-format only
|
||||
- **Workflow Studio Library — API-format DnD enabled** — API-format workflows in the W (Workflows) sub-tab are no longer grayed out; drag & drop and double-click now work for all formats via `app.handleFile()`
|
||||
|
||||
### v0.3.42
|
||||
|
||||
- **Feeder tab — Gallery mode** — new [Image Loop] / [Gallery] toggle in the Feeder tab; Gallery mode drives a WFS_GalleryFeeder node without any external plugin: node selector, group select, sort mode / index / seed, After Gen (Loop / Increment / Fixed), Run / Stop; center pane shows the selected group's images — click any image to jump to that index; `__Feeder__` group is auto-created on first open
|
||||
- **WFS_GalleryFeeder custom node** — new ComfyUI node (Workflow Studio category) that reads images from a Gallery group and outputs one `IMAGE` at a time; inputs: `group_name`, `index`, `sort_mode` (filename_asc / filename_desc / random), `seed`; skips missing files; `IS_CHANGED` ensures re-execution on every queue
|
||||
- **WFS_GalleryFeeder — ComfyUI canvas controls** — `gallery_feeder_extension.js` adds After Gen combo (loop / increment / fixed), ▶ Run button, and ■ Stop button as non-serialized widgets directly on the node in the ComfyUI canvas; manages per-node loop state with `waitForExecution()` Promise pattern; `onRemoved` hook auto-stops the loop on node deletion
|
||||
- **Gallery tab — thumbnail F button** — cyan overlay button (top-left of each image card) toggles `__Feeder__` group membership; active state is always visible in cyan, inactive shows only on hover; initial state derived from `img.groups` returned by `list_images()`
|
||||
- **Gallery tab — `__Feeder__` reserved group** — shown with 🔒 prefix in group dropdowns; rename and delete blocked server-side (403) and client-side (buttons disabled); **FC** toolbar button clears all group members without deleting the group
|
||||
- **Security: XSS fixes in `gallery-tab.js`** — tag names, filenames, and server error messages now escaped via `escapeHtml()` before insertion into `innerHTML` (tag badge list, detail panel `alt` attributes, lightbox footer, folder tree errors, grid errors); `util.js` `escapeHtml` updated to also escape single quotes (`'` → `'`)
|
||||
- **Security: path validation in `gallery_service.py`** — `save_image_meta`, `toggle_favorite`, `add_to_group`, `remove_from_group` now call `_check_path_allowed()` before any file operation; previously these four methods were missing the guard that other write methods already had
|
||||
- **Security: `_init_allowed_root()` in `gallery_routes.py`** — `_allowed_root` is now initialized at module load from saved `gallery_output_dir` (falls back to ComfyUI default output dir); prevents `list_folder_tree` from overriding `_allowed_root` via a crafted `?root=` request, and also fixes serve_image returning 404 in Feeder Gallery mode before the Gallery tab is opened
|
||||
- **Bug fix: WFS_GalleryFeeder seed validation error** — seed `max` changed from `0x7FFFFFFF` to `0xffffffffffffffff` (matching KSampler); `applySeedToWorkflow()` was writing values exceeding the old max, causing `value_bigger_than_max` validation failures
|
||||
|
||||
### v0.3.41
|
||||
|
||||
- **Generate UI — ImpactWildcardEncode / ImpactWildcardProcessor prompt detection** — fixed: wildcard text inputs from comfy-impact-pack nodes were not shown in the Prompt tab of Generate UI
|
||||
- `CLIPTextEncodeEditPlus` node (used as the text encoder in these workflows) now correctly propagates positive/negative roles upstream to ImpactWildcard nodes via BFS
|
||||
- `CLIPTextEncodeEditPlus` with an empty `text_edit` field is no longer added to `prompt_nodes` ahead of the upstream ImpactWildcard node, preventing a blank text area
|
||||
- `widgets[0]` is now always written to `wildcard_text` in the API conversion step, regardless of `object_info` widget-order differences between impact-pack versions
|
||||
- Static fallback mapping added for `ImpactWildcardProcessor` and `ImpactWildcardEncode` so detection works even when the ComfyUI server is offline
|
||||
|
||||
### v0.3.40
|
||||
|
||||
- **AI TOOL tab — Chat pane** — new pane (between Translation and TOOLS) for multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio uses `/v1/chat/completions`
|
||||
- **AI TOOL tab — Wildcard generation** — new "Create wildcards" task in the TOOLS pane dropdown; enter a category name and count, click Run to generate plain-text entries one per line (no markdown, no numbering); output can be pasted directly into wildcard `.txt` files
|
||||
- **Library A tab — Chat and Wildcards** — both features added to the side panel's A tab: Chat as a new sub-tab (Translation → Chat → TOOLS → Settings), wildcard generation as a new option in the TOOLS sub-tab dropdown
|
||||
- **Help updated** — AI TOOL tab help card updated to 6 items (Chat and Wildcards added); Library panel sidepanel-16 updated; EN/JA/ZH
|
||||
|
||||
### v0.3.39
|
||||
|
||||
- **Workflow tab — Send to Canvas button** — replaced "Open in ComfyUI" (toolbar and detail modal) with "Send to Canvas"; stores the selected workflow in `localStorage` for title-drag to the canvas without opening a new browser tab
|
||||
- **Gallery tab — Copy & Send Canvas button** — renamed "Copy Workflow" to "Copy & Send Canvas"; copies the embedded workflow JSON to the clipboard and simultaneously stores it for title-drag
|
||||
- **Workflow Studio Library — title drag** — after "Send to Canvas" or "Copy & Send Canvas" is clicked, the "Workflow Studio Library" panel title turns blue with a green ● indicator; drag the title onto the ComfyUI canvas to load the workflow; title resets automatically after loading
|
||||
|
||||
### v0.3.38
|
||||
|
||||
- **Tagger tab** — new tab for automatic image tagging; supports WD Tagger (ONNX), SwinV2, DeepDanbooru (.h5), and Ollama VLM; 3 sub-tabs: Single (drag & drop / upload, 5 output targets), Batch (folder processing with real-time progress), DB (searchable SQLite database with CSV export)
|
||||
- **Tagger single — GenUI:P button** — new button (left of "Send to Prompt") that appends generated tags to the GenerateUI positive prompt and immediately applies them to the loaded workflow; shows a warning if no workflow is loaded in GenerateUI
|
||||
- **Tagger batch — Write .txt output** — new output option that creates `<filename>.txt` alongside each processed image containing all generated tags (comma-separated)
|
||||
- **Gallery detail panel — Tagger button** — new action button in the Gallery detail panel opens the selected image directly in the Tagger tab for instant single-image tagging
|
||||
- **Gallery detail panel layout** — tabs (Info / JSON / Groups) and action buttons (Metadata / Load GenUI / Tagger) are now on separate rows, preventing button overflow on narrow panels
|
||||
- **Gallery — Save to Gallery tags fix** — tags saved from the Tagger tab were stored as a string instead of an array, causing `forEach is not a function` errors on the next Gallery refresh; now correctly stored as a string array
|
||||
- **GenerateUI — ZIT/Lumina2 workflow prompt fix** — `ConditioningZeroOut` removed from the CONDITIONING passthrough set; previously it propagated the "negative" role upstream through the shared CLIPTextEncode node, marking it as "unknown" and hiding the positive prompt in the GenerateUI Input tab; ZIT (Z-Image Turbo) workflows now correctly show the positive prompt
|
||||
- **Workflow analyzer — lumina2 model type** — CLIPLoader `type: "lumina2"` is now correctly classified as Z-IMAGE in the workflow type detector
|
||||
|
||||
### v0.3.37
|
||||
|
||||
- **Models tab — Select All button** — new button added to the right of "Deselect All" in the multi-select action bar; selects all models currently visible after applying the active search / filter / group filters
|
||||
- **Gallery tab — Select All button** — new button added to the right of "Deselect All" in the bulk action bar; selects all images in the current folder view
|
||||
- **Gallery bulk bar — i18n** — all bulk action bar buttons and the selection count were hard-coded English strings; they now use the `t()` translation system (EN / JA / ZH) so the bar respects the selected UI language
|
||||
|
||||
### v0.3.36
|
||||
|
||||
- **GenerateUI Save — overwrite protection** — saving now checks for an existing file and asks for confirmation before overwriting; if the target file is in UI format, a dedicated warning explains that saving in API format will lose the node layout
|
||||
- **GenerateUI Save — Raw JSON sync** — unapplied Raw JSON edits are now included in the save (parsed and synced back to the editor); invalid JSON aborts the save with an error instead of silently saving stale content
|
||||
- **GenerateUI Save — hardening** — filename is set via DOM (no HTML-attribute injection), image extensions (`.png` etc.) are stripped from the default name, and a double-submit guard prevents duplicate POSTs
|
||||
- **Help i18n fixed** — v0.3.35 help text updates (Save button, table sort, JSON tab rename) were being overwritten at startup by stale i18n strings and never displayed; all help keys synced in EN / JA / ZH and new entries registered in the help ID map
|
||||
- **Toast i18n** — ~115 hard-coded English toasts replaced with translated messages (~60 new shared keys in EN / JA / ZH); Settings labels for Text Size / RAW JSON Colors that displayed raw key names are now properly defined
|
||||
- **Refactoring** — new shared `util.js` (`escapeHtml` unified across 5 files — the Metadata tab copy was missing `"` escaping; `readJsonStorage` / `getSettings` replace 15 direct `localStorage` parses); Models table sort now precomputes sort keys (faster on large libraries); Gallery action buttons moved off the tab-button class, removing all `!important` CSS overrides
|
||||
- **README refreshed** — intro rewritten around the three pillars (Management / GenerateUI / Library side panel); new screenshots: GenUI LoRA Stack, GenUI Batch, Models Multi-select Menu; WS Library image updated
|
||||
|
||||
### v0.3.35
|
||||
|
||||
- **Models tab — Table column sort** — click any column header to sort ascending (▲), click again for descending (▼), click a third time to clear; active column name highlighted in accent color; Enable/Disable column header now labeled **E/D**
|
||||
- **Gallery tab — Load GenUI button** — new button to the right of the Metadata button in the detail panel; loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab; warning toast shown if no workflow is embedded or the format is unsupported; Metadata button restyled green, Load GenUI uses primary accent color
|
||||
- **GenerateUI tab — Save button** — new button at the right end of the subtab row; opens a filename dialog (default: current workflow name) and saves the current workflow as a `.json` file to the Workflow tab via the import API; workflow name label updates to the saved filename on success
|
||||
|
||||
### v0.3.33
|
||||
|
||||
- **LoRA pane — workflow load no longer overwrites LoraManager** — removed the auto-apply block that replaced `inputs.loras` / `inputs.text` with Stack group contents on every workflow load; Stack is now applied only when the Apply button is pressed
|
||||
- **LoRA pane — 🔄 button no longer resets to Single tab** — active tab state is saved before `innerHTML` rebuild and restored afterwards; staying on Stack tab after refresh
|
||||
- **Models tab — Stack group hidden for non-LoRA types** — when switching to Checkpoint or other non-LoRA types, the Stack group is now excluded from the group filter dropdown and side-panel group management (LoRA-only concept)
|
||||
- **CLIPTextEncodeEditPlus widget mapping fix** — `convertUiToApi` no longer advances `wIdx` for UI slot inputs that have an actual link, preventing index misalignment when `object_info` lists optional STRING inputs (e.g. `text1`) before widget inputs (e.g. `text_edit`); fixes `+af` appearing as the positive prompt instead of the edit text
|
||||
- **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
|
||||
- **LoRA UI layout fixes** — "Stack" label moved from ID row to Strength row left of the toggle-all checkbox; ID dropdown expanded to `flex:1` (no max-width cap); M/C input width 54 px → 60 px to prevent value truncation
|
||||
- **Models tab — CivitAI default** — side panel opens to the CivitAI tab by default when a model is selected
|
||||
- **Help tab — sidebar navigation** — redesigned as 2-column layout: left sidebar (14 topics + Support fixed at bottom) + right content pane; content switches on click
|
||||
- **Input tab — Prompt/Image tabs + taller textboxes** — Prompt and Image split into inner tabs; Positive prompt rows 4 → 8, Negative prompt rows 3 → 6
|
||||
- **Reset Workflow button** — added to the GenerateUI toolbar; re-reads the loaded workflow file from disk and reloads the editor
|
||||
- **LoRA Stack trigger words** — CivitAI trigger words for active stack models appended to Positive prompt on Apply; diff-updated on re-apply
|
||||
- **Feeder subtab UI** — (root) folder selected automatically on tab open; PREVIEW pane moved to the far right of the layout; preview auto-updates to the current processing image during a RUN
|
||||
- **Batch tab — Sampler / Scheduler** — left pane gains Sampler and Scheduler tabs (lists fetched from ComfyUI); Batch Queue gains two new columns; batch runs each value through KSampler sequentially
|
||||
- **Lora Loader (LoraManager) support** — workflow analysis, single Apply, Stack Apply, and Batch all write `inputs.loras.__value__` array + `inputs.text` syntax for LoraManager nodes
|
||||
- **Stack enable/disable** — per-model checkbox and Toggle All checkbox; disabled models excluded from syntax/prompt; LoraManager apply preserves them as `active: false`
|
||||
- **Global strength adjuster** — `[−][step][+]` controls for Model and CLIP strength applied to all stack models at once
|
||||
- **model-and-prompt-from-metadata node support** — `CLIPTextEncodeEditPlus`, `ImageMetadataCheckpointLoader`, `ImageMetadataPromptLoader`, `ImageMetadataLoRALoader` added to workflow analysis
|
||||
- **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
|
||||
- **CivitAI tab click refresh** — clicking the CivitAI tab in the side panel now re-renders its content, ensuring the latest fetch state is always shown without needing to reselect the model
|
||||
- **Bug fix: model page URL broken after batch fetch** — the batch `POST /model-versions/by-hash` endpoint can omit `model.id` inside the version object; `_extract_info` now falls back to the top-level `modelId` field so the model page link is always correct; existing cached entries with a broken URL are also fixed client-side from `modelId` + `versionId`
|
||||
- **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
|
||||
- **Richer metadata** — `_extract_info` now captures `nsfwLevel` (numeric), `air` (AIR URN identifier), `stats` (download count, thumbs-up/down), `updatedAt`, `publishedAt`, image dimensions / per-image NSFW level (`imageDetails`), and primary file precision / format (`fileMeta`)
|
||||
- **Hash consistency** — cache keys use lowercase hex; API requests send uppercase hex as required by the CivitAI spec
|
||||
- **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
|
||||
- **Settings sub-tab** — backend radio (Ollama / LM Studio), API URL input, connection test with live status, model selector with refresh, Free language name fields (source and destination); saved to `localStorage` key `wfm_ai_settings` and shared between SPA and Library panel
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme before any network call
|
||||
- **Settings Tab — Ollama section renamed** — "Ollama 設定" → "Ollama 設定(プロンプトタブ)" to distinguish it from the new AI tab's settings
|
||||
- **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
|
||||
- Prompts sub-tab: drag a prompt to place `CLIPTextEncode` with the full text pre-filled
|
||||
- **Extended node type support** — `UnetLoaderGGUF` / `UNETLoaderGGUF` now detected as Diffusion Model; `QuadrupleCLIPLoader` (HiDream and similar 4-CLIP workflows) now detected as Text Encoder
|
||||
- **Bug fix: CLIPLoader widget name** — `textencoder` type in `MODEL_NODE_MAP` was incorrectly using `clip_name1` (DualCLIPLoader's widget) instead of `clip_name`; Text Encoder models now drop with the correct filename
|
||||
- **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
|
||||
- **Workflow viewer improved** — Metadata tab now reads both `prompt` (API format, the default for most ComfyUI-generated images) and `workflow` (UI format) keys from PNG metadata, covering virtually all ComfyUI output
|
||||
- **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
|
||||
- **Bulk group operations** — with models selected, add or remove them from any group in one click, or create a new group and add them simultaneously
|
||||
- **Bulk file delete** — permanently delete selected model files along with all associated preview images and sidecar files (`.json`, `.civitai.info`, `.info`); requires confirmation before deleting
|
||||
- **Bug fix** — models in subdirectories could not be disabled due to a path-doubling error; fixed by computing the rename target from `path.name` instead of `model_name`
|
||||
- **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
|
||||
- **Gallery output directory setting** — configure the output folder scanned by Gallery tab from Settings
|
||||
- **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
|
||||
- **Sidebar All views** — ★ star displayed for favorite items in Workflows and Nodes All sub-tabs
|
||||
- **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
|
||||
- **Model metadata** — persistent favorites, tags, memo, and SHA256 hash per model
|
||||
- **Preview images** — auto-detect and display preview images, change via file upload in detail modal
|
||||
- **Table view memo column** — memo displayed in table view with compact subdir/extension columns
|
||||
- **Side panel file path** — display full file path with click-to-copy in model info panel
|
||||
- **Node card redesign** — removed package badge, added left border color-coding by package
|
||||
- **Toolbar pagination** — pagination controls moved to toolbar for all tabs (Models, Workflows, Nodes)
|
||||
- **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)
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **GenUI Set** — renamed "Apply" button to clarify its purpose (applies presets to GenerateUI)
|
||||
- **Sidebar P/N copy buttons** — copy positive or negative prompts individually from sidebar items
|
||||
- **Panel renamed** — "WF & Node Library" renamed to "Workflow Studio Library"
|
||||
- **Backend API for presets** — presets migrated from localStorage to server-side API with one-time migration
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -1005,7 +539,8 @@ ComfyUI-Workflow-Studio/
|
||||
│ │ ├── prompts_routes.py # Prompt presets CRUD API
|
||||
│ │ ├── settings_routes.py # Settings API
|
||||
│ │ ├── ollama_routes.py # Ollama proxy API
|
||||
│ │ └── eagle_routes.py # Eagle integration API
|
||||
│ │ ├── eagle_routes.py # Eagle integration API
|
||||
│ │ └── gallery_routes.py # Gallery tab API (Output + ImagePrompt + Style_Catalog sub-tabs)
|
||||
│ └── services/
|
||||
│ ├── workflow_service.py # Workflow file operations
|
||||
│ ├── nodes_service.py # Node metadata & node sets
|
||||
@@ -1014,7 +549,9 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── prompts_service.py # Prompt presets persistence
|
||||
│ ├── workflow_analyzer.py # Model/node detection
|
||||
│ ├── settings_service.py # Settings persistence
|
||||
│ └── png_extractor.py # PNG metadata extraction
|
||||
│ ├── png_extractor.py # PNG metadata extraction
|
||||
│ ├── gallery_service.py # Gallery image scanning, .txt sidecar & ponyxlWildcardsVault prompt resolution
|
||||
│ └── gallery_metadata.py # Gallery per-image metadata store (favorites/tags/memo/groups)
|
||||
├── templates/
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help/AI)
|
||||
├── static/
|
||||
@@ -1025,7 +562,9 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── workflow-tab.js # Workflow browser
|
||||
│ ├── generate-tab.js # Generation UI
|
||||
│ ├── feeder-tab.js # Feeder subtab (Image Loop + Gallery modes)
|
||||
│ ├── gallery-tab.js # Gallery tab (image browser, groups, metadata)
|
||||
│ ├── gallery-tab.js # Gallery tab: Output sub-tab (image browser, groups, metadata) + sub-tab switching
|
||||
│ ├── image-prompt-tab.js # Gallery tab: ImagePrompt sub-tab (visual prompt builder)
|
||||
│ ├── style-catalog-tab.js # Gallery tab: Style_Catalog sub-tab (visual Style picker)
|
||||
│ ├── tagger-tab.js # Tagger tab (WD Tagger, DeepDanbooru, Ollama VLM)
|
||||
│ ├── prompt-tab.js # AI assistant & presets
|
||||
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
|
||||
@@ -1043,6 +582,8 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── top_menu_extension.js # ComfyUI menu bar integration
|
||||
│ ├── node_sets_menu.js # Workflow Studio Library side panel
|
||||
│ └── gallery_feeder_extension.js # WFS_GalleryFeeder canvas widgets (After Gen / Run / Stop)
|
||||
├── tools/
|
||||
│ └── import_style_prompt_seed.py # Dev tool: pre-process a wildcards-vault source into ws_image_prompt (not run by the plugin itself)
|
||||
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,745 @@
|
||||
# Graph Report - ComfyUI-Workflow-Studio (2026-08-20)
|
||||
|
||||
## Corpus Check
|
||||
- 140 files · ~0 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 2627 nodes · 5787 edges · 171 communities (119 shown, 52 thin omitted)
|
||||
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.66)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- Models Tab Frontend
|
||||
- Models API Routes
|
||||
- Tagger API Routes
|
||||
- Image Edit Mask Tools (Code)
|
||||
- Prompt Tab Frontend
|
||||
- Gallery API Routes
|
||||
- Gallery Tab Frontend & Toasts
|
||||
- Image Edit Layer & Draw Tools
|
||||
- Tagger/Prompt i18n Helpers
|
||||
- GenerateUI Feeder Tab
|
||||
- Nodes API Routes
|
||||
- Settings API Routes
|
||||
- App Shell & i18n Init
|
||||
- Nodes Tab Frontend
|
||||
- GenerateUI Lab Tab
|
||||
- Metadata Tab Frontend
|
||||
- Node Sets Menu & Sidebar
|
||||
- AI Tool Tab Frontend
|
||||
- Generate Tab Frontend & Modals
|
||||
- Workflow Tab Frontend & Storage
|
||||
- Wildcard Service & Routes
|
||||
- CivitAI Service & Data Import/Export
|
||||
- GenerateUI Tab & AI Chat Features
|
||||
- ComfyUI Client & Editor
|
||||
- DEVLOG v0.1.x Early Features
|
||||
- AI Chat/VLM Sidebar Helpers
|
||||
- AI TOOL Tab Backends
|
||||
- Node Sets Menu UI Components
|
||||
- Node Sets Draggable Items
|
||||
- Workflow API Routes
|
||||
- Gallery Metadata Store
|
||||
- Workflow Service Metadata/Import
|
||||
- CLAUDE.md Node Dev Rules
|
||||
- Lab Tab & PNG Metadata Utilities
|
||||
- Lab Plan Service & Routes
|
||||
- Skill Service & Routes
|
||||
- Image Edit Select Tool
|
||||
- Image Edit Draw Tool
|
||||
- Image Edit Layer Manager Core
|
||||
- CivitAI Service
|
||||
- Image Edit Mask Tool
|
||||
- Gallery Service Core
|
||||
- Eagle Routes Backend
|
||||
- Style Catalog Tab
|
||||
- Image Prompt Tab
|
||||
- SDXL 3D Pose Editor DWPose Workflow
|
||||
- Gallery Service Core
|
||||
- Image Edit Tab & Mask Editor One
|
||||
- App Shell & Nodes Tab Ecosystem
|
||||
- Node Sets Menu Model Fetch
|
||||
- Image Edit Background Removal
|
||||
- ComfyUI Workflow Utilities
|
||||
- Generate Tab Frontend & Modals
|
||||
- Batch Group UI & Security Fixes
|
||||
- DEVLOG Early History & Manager Registration
|
||||
- App Shell & ComfyUI Ecosystem Refs
|
||||
- GenerateUI Batch & Workflow Editor Core
|
||||
- Image Edit Blur Tool (BlurTool.js)
|
||||
- Image Edit Shape Tool
|
||||
- Tagger Tab & Gallery Bulk Actions
|
||||
- Image Edit Layer Core & Bug Fixes
|
||||
- G'MIC Routes
|
||||
- Ollama Routes
|
||||
- Gallery Metadata/Service Bridge
|
||||
- Image Edit Text Tool
|
||||
- Mask Editor One BiRefNet/SAM3 Integration
|
||||
- Eagle Routes Backend
|
||||
- Prompts API Routes/Service
|
||||
- Workflow Analyzer
|
||||
- Gallery Tab Core & Feeder Group
|
||||
- AI TOOL Backend & Model Tab Updates
|
||||
- Tagger Tab Implementation & Security Fixes
|
||||
- Eagle Routes Backend
|
||||
- Prompts API Routes/Service
|
||||
- Metadata & Nodes Tab Core
|
||||
- AI Tool Tab Frontend
|
||||
- Node Sets Menu & Sidebar
|
||||
- Gallery Feeder Extension (Frontend)
|
||||
- Gallery Speed & Bulk Features
|
||||
- Style Catalog & Batch Loop Core
|
||||
- Metadata Tab Node Support History
|
||||
- Image Edit Background Removal (BgRemove.js)
|
||||
- Gallery Feeder Node (Backend)
|
||||
- App Shell & i18n Init
|
||||
- Generate Tab Frontend & Modals
|
||||
- Image Edit Inpaint/I2I Actions
|
||||
- Image Edit SAM3 Segmentation
|
||||
- Node Sets Metadata Extraction
|
||||
- Node Sets Prompt Extraction
|
||||
- Inpaint Basic Workflow
|
||||
- Gallery Feeder & Batch Features
|
||||
- Gallery Service Background Indexing
|
||||
- Settings/CivitAI Config Resolution
|
||||
- Gallery Service Vault Helpers
|
||||
- GenerateUI Lab Tab
|
||||
- Style Prompt Seed Import Tool
|
||||
- FireRed 3D Pose Image Edit Workflow
|
||||
- Chat Image Generation & Inpaint Bridge
|
||||
- Subgraph Workflow Support & Fixes
|
||||
- KREA-2 & Lab Tab Extensions
|
||||
- LoRA Pane Evolution & Help Tab
|
||||
- Image Edit File Export (FileExport.js)
|
||||
- Image Edit G'MIC Integration
|
||||
- App Shell & i18n Init
|
||||
- DWPose Image Feeder Workflow
|
||||
- AI Tab & RAW JSON Search History
|
||||
- Prompt Extraction Bug Fixes
|
||||
- Comic Creator Bridge & LoRA Batch Fixes
|
||||
- Settings/CivitAI Config Resolution
|
||||
- Node Sets Menu Model Fetch
|
||||
- G'MIC Integration & Security Fixes
|
||||
- Comic Creator Integration Bridges
|
||||
- GenerateUI Model Tab GGUF/Embedding Support
|
||||
- Bypass Node Handling & Advanced Sampling
|
||||
- Prompt Text Node
|
||||
- Gallery Metadata/Service Bridge
|
||||
- Gallery Service Vault Helpers
|
||||
- PNG Metadata Extractor
|
||||
- AI Skills & Help Redesign
|
||||
- Prompt Extraction Bug Fixes
|
||||
- AI TOOL Chat & Wildcard Features
|
||||
- Config & Service Modules
|
||||
- Group Feature Path Bug Fixes
|
||||
- SVG Generation & Display Support
|
||||
- Send to Canvas Feature
|
||||
- Module Split Refactor (v0.4.0)
|
||||
- CivitAI Detail Panel History
|
||||
- GenerateUI Layout & JSON Highlight
|
||||
- Workflow Tab Bug Fixes
|
||||
- CivitAI Batch Fetch Optimization
|
||||
- DEVLOG JSON Highlighting & Subgraph
|
||||
- Mask Tool 5 Sub-tools
|
||||
- Prestartup Script
|
||||
- Workflow Service Dir Update
|
||||
- Project Banner Asset
|
||||
- Project Icon Asset
|
||||
- SDXL 2D Pose Editor I2I Workflow
|
||||
- Tagger Tab Screenshot
|
||||
- Models Tab Screenshot
|
||||
- Topbar Icon Strip Screenshot
|
||||
- GenerateUI LoRA Stack Screenshot
|
||||
- Favicon Asset
|
||||
- FireRed 2D Pose Image Edit Workflow
|
||||
- Flux.2 Klein 3D Pose Image Edit Workflow
|
||||
- SDXL DWPose ControlNet Workflow
|
||||
- SDXL From Metadata Workflow
|
||||
- SDXL I2I FaceDetailer Workflow
|
||||
- SDXL Image Feeder I2I Workflow
|
||||
- SDXL T2I FaceDetailer Workflow
|
||||
- Default View Setting
|
||||
- Latent Resolution Presets
|
||||
- Send to Canvas Toast Fix
|
||||
- ZIT/Lumina2 Prompt Fix
|
||||
- GenerateUI Batch Tab Screenshot
|
||||
- Multiple Select Menu Screenshot
|
||||
- Image Edit Tab Screenshot
|
||||
- Workflow Tab Screenshot
|
||||
- Prompt Input Assistance Screenshot
|
||||
- GenerateUI Feeder Screenshot
|
||||
- Library Panel LoRA Info Screenshot
|
||||
- Library Panel Prompts Screenshot
|
||||
- Settings Tab Screenshot
|
||||
- Project Thumbnail Asset
|
||||
- Project Manifest (pyproject.toml)
|
||||
- Main Stylesheet
|
||||
- SD1.5 T2I Basic Workflow
|
||||
- SDXL T2I Basic Workflow Screenshot
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `t()` - 174 edges
|
||||
2. `showToast()` - 138 edges
|
||||
3. `escapeHtml()` - 57 edges
|
||||
4. `ImageEditTab` - 53 edges
|
||||
5. `GalleryService` - 45 edges
|
||||
6. `initPromptTab()` - 34 edges
|
||||
7. `setup_routes()` - 32 edges
|
||||
8. `initSettingsTab()` - 31 edges
|
||||
9. `add()` - 31 edges
|
||||
10. `renderModelGrid()` - 28 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `Sidepanel Information (I) Tab` --semantically_similar_to--> `Metadata Tab` [INFERRED] [semantically similar]
|
||||
templates/index.html → README.md
|
||||
- `Batch Type: Checkpoint` --shares_data_with--> `Models Tab` [EXTRACTED]
|
||||
templates/index.html → README.md
|
||||
- `Workflow Tab` --shares_data_with--> `Badge System (shared)` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
- `Settings Tab` --conceptually_related_to--> `Settings: Connections (ComfyUI/Ollama/Eagle)` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
- `Settings Tab` --conceptually_related_to--> `Settings: Workflow Defaults` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
|
||||
## Import Cycles
|
||||
- 3-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 3-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/GmicIntegration.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/FileExport.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/InpaintI2IActions.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BgRemove.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 5-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 5-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Gallery Feeder System** — devlog_gallery_feeder_node, devlog_gallery_feeder_canvas_controls, devlog_feeder_group_protection, devlog_feeder_tab_gallery_mode [EXTRACTED 0.90]
|
||||
- **LoRA Pane Iterative Evolution (v0.3.29-v0.3.33)** — devlog_v0_3_29_lora_section_integration, devlog_v0_3_30_lora_trigger_bugfix, devlog_v0_3_31_lora_single_stack_tabs, devlog_v0_3_32_loramanager_detection, devlog_v0_3_33_lora_pane_bugfix [EXTRACTED 0.90]
|
||||
- **Shared Badge System Across Workflow and Models Tabs** — templates_index_badge_system, readme_workflow_tab, readme_models_tab [EXTRACTED 1.00]
|
||||
- **ComfyUI Comic Creator Bidirectional Bridge Functions** — devlog_comic_creator_integration, devlog_v0_3_69_send_cc_button, devlog_i2i_bridge_receive_image, devlog_v0_3_76_i2i_run_bridge, devlog_v0_3_72_comic_creator_inpaint_entrypoint, devlog_v0_3_80_comic_creator_bridge [EXTRACTED 1.00]
|
||||
- **Mask Editor One Integration Features (BiRefNet/SAM3/ABR/Icon Fix/Connect Button)** — devlog_mask_editor_one_custom_node, devlog_birefnet_bg_remove_integration, devlog_sam3_segmentation_integration, devlog_mask_tool_abr_brush_support, devlog_bg_image_b64_field_rename_fix, devlog_v0_4_0_mask_editor_one_connect_button [EXTRACTED 1.00]
|
||||
- **Feeder Group Image Queueing Flow** — readme_feeder_group, readme_gallery_tab, templates_index_feeder_gallery_mode, templates_index_wfs_galleryfeeder_node [EXTRACTED 1.00]
|
||||
- **node_sets_menu.js Sidepanel Evolution Across Versions** — devlog_v0_1_7_nodes_tab, devlog_v0_1_8_wf_node_library, devlog_v0_1_9_sidepanel_improvements, devlog_v0_2_0_prompt_presets, devlog_v0_2_3_badge_unification_genui_model, devlog_v0_2_5_category_package_theme [EXTRACTED 1.00]
|
||||
- **AI TOOL Tab multi-backend support** — readme_ai_tool_tab, readme_ollama, readme_lm_studio, readme_lemonade_server, readme_unsloth [EXTRACTED 1.00]
|
||||
- **Feeder generation-loop ecosystem** — readme_feeder_subtab, readme_image_loop_mode, readme_gallery_mode, readme_wfs_galleryfeeder_node, readme_feeder_group [EXTRACTED 1.00]
|
||||
- **GenerateUI 6-tab layout** — readme_generateui, readme_feeder_subtab, readme_batch_tab, readme_lab_subtab [EXTRACTED 1.00]
|
||||
- **py/routes API layer (per-tab route modules)** — readme_workflow_routes_py, readme_models_routes_py, readme_nodes_routes_py, readme_settings_routes_py, readme_gallery_routes_py [EXTRACTED 1.00]
|
||||
- **GenerateUI Style System (shared named-Style registry)** — readme_style_manager, readme_generateui, readme_batch_tab, readme_style_catalog_gallery_subtab, readme_create_catalog [EXTRACTED 1.00]
|
||||
- **Release & Registry Publish Flow** — claude_release_procedure, github_workflows_publish_trigger, readme_version_badge, github_workflows_publish_action [EXTRACTED 1.00]
|
||||
- **Shared Style File System Across Tabs** — templates_index_style_system, templates_index_style_feature, templates_index_style_manager_prompt, templates_index_batch_style_type, templates_index_create_catalog_feature, templates_index_gallery_stylecatalog_panel [EXTRACTED 1.00]
|
||||
- **Path Traversal Protection Pattern (_safe_path)** — devlog_v0_1_6_path_traversal_fix, devlog_v0_3_3_wildcard_panel, devlog_v0_2_8_data_dir_export_import [INFERRED 0.70]
|
||||
- **Shared Badge Palette System (Workflow/Models tabs)** — devlog_v0_2_3_badge_unification_genui_model, devlog_v0_3_0_model_group_display, devlog_v0_2_9_model_enable_disable [INFERRED 0.80]
|
||||
- **Batch Reserved Group System** — devlog_reserved_groups_mechanism, devlog_models_batch_group_ui, devlog_prompt_workflow_batch_group_ui, devlog_v0_3_24 [INFERRED 0.80]
|
||||
- **CivitAI Integration Pipeline** — devlog_v0_3_17, devlog_v0_3_19, devlog_v0_3_20, devlog_v0_3_21 [INFERRED 0.80]
|
||||
- **ComfyUI Custom Node Robustness Patterns** — claude_is_changed_caching, claude_validate_inputs_pitfall, claude_hidden_inputs, claude_lazy_inputs, claude_safe_node_loading [INFERRED 0.85]
|
||||
- **Recurring Dev/Runtime Deployment Sync Failures Across Versions** — devlog_dev_deploy_sync_reminder, devlog_v0_3_69_send_cc_button, devlog_v0_3_70_ollama_404_prompt_weight [INFERRED 0.85]
|
||||
- **LoraManager Integration** — devlog_v0_3_17, devlog_v0_3_25, devlog_v0_3_26, devlog_lora_manager_format [INFERRED 0.85]
|
||||
- **Image Edit Tab Tool Suite** — devlog_v0_3_53_image_edit_tab, devlog_v0_3_54_shape_tool, devlog_v0_3_57_blur_tool, devlog_v0_3_57_bgremove_tool, devlog_v0_3_59_mask_tool, devlog_gmic_qt_filter_tool [INFERRED 0.90]
|
||||
- **Mask Editor One Inpainting Pipeline** — comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_load_checkpoint_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_mask_editor_one_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_positive_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_negative_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_vae_encode_for_inpainting_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_save_image_node [INFERRED]
|
||||
|
||||
## Communities (171 total, 52 thin omitted)
|
||||
|
||||
### Community 0 - "Models Tab Frontend"
|
||||
Cohesion: 0.10
|
||||
Nodes (77): badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange(), openBadgeEditModal(), renderBadgeFilter() (+69 more)
|
||||
|
||||
### Community 1 - "Models API Routes"
|
||||
Cohesion: 0.06
|
||||
Nodes (55): handle_change_preview(), handle_civitai_batch(), handle_civitai_cache(), handle_civitai_fetch(), handle_delete_models(), handle_get_disabled(), handle_get_filepath(), handle_get_groups() (+47 more)
|
||||
|
||||
### Community 2 - "Tagger API Routes"
|
||||
Cohesion: 0.07
|
||||
Nodes (29): Connection, Image, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list() (+21 more)
|
||||
|
||||
### Community 3 - "Image Edit Mask Tools (Code)"
|
||||
Cohesion: 0.05
|
||||
Nodes (6): MASK_TEXT_FONTS, MaskAlphaTool, MaskColorTool, MaskShapeTool, MaskTextTool, MaskVectorTool
|
||||
|
||||
### Community 4 - "Prompt Tab Frontend"
|
||||
Cohesion: 0.08
|
||||
Nodes (59): refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile(), chatWithAi() (+51 more)
|
||||
|
||||
### Community 5 - "Gallery API Routes"
|
||||
Cohesion: 0.10
|
||||
Nodes (54): add_to_group(), bulk_favorite(), bulk_group(), clear_group_images(), create_folder_route(), create_group(), delete_folder_route(), delete_group() (+46 more)
|
||||
|
||||
### Community 6 - "Gallery Tab Frontend & Toasts"
|
||||
Cohesion: 0.10
|
||||
Nodes (53): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+45 more)
|
||||
|
||||
### Community 8 - "Tagger/Prompt i18n Helpers"
|
||||
Cohesion: 0.11
|
||||
Nodes (50): _appendSvgPreview(), closeModal(), showToast(), _applyDefaultCheckpointIfEnabled(), handleGenerate(), loadWorkflowIntoEditor(), openCatalogCreateModal(), saveCurrentWorkflow() (+42 more)
|
||||
|
||||
### Community 9 - "GenerateUI Feeder Tab"
|
||||
Cohesion: 0.10
|
||||
Nodes (50): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+42 more)
|
||||
|
||||
### Community 10 - "Nodes API Routes"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+20 more)
|
||||
|
||||
### Community 11 - "Settings API Routes"
|
||||
Cohesion: 0.09
|
||||
Nodes (44): _apply_import_bundle(), _build_export_bundle(), _find_style(), _get_comfyui_output_dir(), handle_create_style(), handle_delete_style(), handle_export(), handle_get() (+36 more)
|
||||
|
||||
### Community 12 - "App Shell & i18n Init"
|
||||
Cohesion: 0.09
|
||||
Nodes (43): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+35 more)
|
||||
|
||||
### Community 13 - "Nodes Tab Frontend"
|
||||
Cohesion: 0.12
|
||||
Nodes (42): buildPromptItem(), _applyNodeSelectionToDOM(), bulkNodeAddToGroup(), bulkNodeCreateAndAddToGroup(), bulkNodeRemoveFromGroup(), bulkNodeSetFavorite(), categoryBadgeHtml(), createNodeCard() (+34 more)
|
||||
|
||||
### Community 14 - "GenerateUI Lab Tab"
|
||||
Cohesion: 0.12
|
||||
Nodes (41): openModal(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildPlanData(), _buildWorkflowForIteration(), _cellHtml(), _cellLabel() (+33 more)
|
||||
|
||||
### Community 15 - "Metadata Tab Frontend"
|
||||
Cohesion: 0.11
|
||||
Nodes (39): buildLoRAItem(), buildModelItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints(), extractDiffusionModels() (+31 more)
|
||||
|
||||
### Community 16 - "Node Sets Menu & Sidebar"
|
||||
Cohesion: 0.09
|
||||
Nodes (38): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+30 more)
|
||||
|
||||
### Community 17 - "AI Tool Tab Frontend"
|
||||
Cohesion: 0.12
|
||||
Nodes (38): appendChatBubble(), _appendSkillSaveButton(), buildTranslationMessages(), cleanTranslationOutput(), _extractSkillBlock(), _extractSvgCode(), fetchModels(), generateImageFromChat() (+30 more)
|
||||
|
||||
### Community 18 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.09
|
||||
Nodes (37): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _ckptBatch, _ckptState (+29 more)
|
||||
|
||||
### Community 19 - "Workflow Tab Frontend & Storage"
|
||||
Cohesion: 0.13
|
||||
Nodes (34): badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows(), filterWorkflows() (+26 more)
|
||||
|
||||
### Community 20 - "Wildcard Service & Routes"
|
||||
Cohesion: 0.12
|
||||
Nodes (21): handle_create_link(), handle_delete(), handle_get_content(), handle_link_status(), handle_list(), handle_remove_link(), handle_save(), Application (+13 more)
|
||||
|
||||
### Community 21 - "CivitAI Service & Data Import/Export"
|
||||
Cohesion: 0.06
|
||||
Nodes (36): CivitAI, CivitAI Integration, py/services/civitai_service.py, Data Management (export/import), G'MIC-Qt Integration, Highlight color (v0.3.98), static/js/i18n.js, static/js/json-highlight.js (+28 more)
|
||||
|
||||
### Community 22 - "GenerateUI Tab & AI Chat Features"
|
||||
Cohesion: 0.07
|
||||
Nodes (36): ComfyUI-Impact-Pack (external), Eagle Integration, GenerateUI Tab, Lab Subtab, Wildcard Integration (Settings), Alt+Click Apply & Generate, Batch Type: Checkpoint, Batch Type: Prompt (+28 more)
|
||||
|
||||
### Community 23 - "ComfyUI Client & Editor"
|
||||
Cohesion: 0.09
|
||||
Nodes (26): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+18 more)
|
||||
|
||||
### Community 24 - "DEVLOG v0.1.x Early Features"
|
||||
Cohesion: 0.08
|
||||
Nodes (30): Feeder Tab (GenerateUI), Gallery タブ追加 & 全タブ Group UI 統一, node_sets_menu.js (ComfyUI Sidepanel Library), プロジェクトリネーム & アイコン適用(Workflow-Manager→Workflow-Studio), プロンプトタブ 中国語↔英語翻訳追加, テーマカスタマイズ機能・設定タブ改善, トップバーボタンアイコン修正・Appモードバッジ調査, v0.1.0 リリース(GitHub公開・ComfyUI Manager登録) (+22 more)
|
||||
|
||||
### Community 25 - "AI Chat/VLM Sidebar Helpers"
|
||||
Cohesion: 0.11
|
||||
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
|
||||
|
||||
### Community 26 - "AI TOOL Tab Backends"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): static/js/ai-tab.js, AI TOOL Tab, Lemonade Server, LM Studio, Ollama, py/routes/ollama_routes.py, Prompt Tab, static/js/prompt-tab.js (+20 more)
|
||||
|
||||
### Community 27 - "Node Sets Menu UI Components"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), NODE_SETS_TOOLTIP, attachTopMenuButton(), beforeRegisterNodeDef(), captureCanvasSnapshot(), compareVersions() (+19 more)
|
||||
|
||||
### Community 28 - "Node Sets Draggable Items"
|
||||
Cohesion: 0.16
|
||||
Nodes (28): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), esc(), extractPackageName(), getNodeCategory(), getNodePackage(), getWfBadges() (+20 more)
|
||||
|
||||
### Community 29 - "Workflow API Routes"
|
||||
Cohesion: 0.15
|
||||
Nodes (26): handle_analyze(), handle_change_thumbnail(), handle_delete(), handle_import(), handle_list(), handle_metadata(), handle_raw(), handle_reanalyze_all() (+18 more)
|
||||
|
||||
### Community 30 - "Gallery Metadata Store"
|
||||
Cohesion: 0.13
|
||||
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
|
||||
|
||||
### Community 31 - "Workflow Service Metadata/Import"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): Get raw workflow JSON content., Save metadata (tags, memo, summary, etc.) for a workflow., Manages workflow files and metadata., Import workflow files. Returns list of results., Rename workflow and its associated thumbnail., Delete workflow JSON and associated thumbnail., Re-analyze a workflow and save results to metadata., Re-analyze all workflows and update metadata. (+5 more)
|
||||
|
||||
### Community 32 - "CLAUDE.md Node Dev Rules"
|
||||
Cohesion: 0.10
|
||||
Nodes (25): CLAUDE.md Development Guidelines, Bounded Module-level Cache Pattern, ComfyUI Restart Required After Python Change, Pre-Commit Checklist, Loose Dependency Pinning Policy, Source Repo vs custom_nodes Deploy Sync, folder_paths-based File Path Handling, Hidden Inputs (UNIQUE_ID/PROMPT/EXTRA_PNGINFO) (+17 more)
|
||||
|
||||
### Community 33 - "Lab Tab & PNG Metadata Utilities"
|
||||
Cohesion: 0.09
|
||||
Nodes (25): wfm:apply-and-generate Custom Event (Circular Import Avoidance), embedPngTextChunk() iTXt PNG Chunk Embedding (util.js), extractAllMetadata() Exported Reusable Prompt Extraction, Gallery Root Folder Label Simplification ("[root]"), Lab Index Image Save-to-Output + Eagle Auto-Save, Lab Keyframe Data Model (atIteration/value/revertToBase), Lab Plan JSON "Open Workflow" Button (Dynamic Import), Lab Tab Plan JSON Sub-tab (Refresh/Apply to Setting) (+17 more)
|
||||
|
||||
### Community 34 - "Lab Plan Service & Routes"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+6 more)
|
||||
|
||||
### Community 35 - "Skill Service & Routes"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): handle_delete(), handle_get_content(), handle_list(), handle_save(), Application, Request, Response, AI skill (.md system prompt) file management API routes. (+8 more)
|
||||
|
||||
### Community 39 - "CivitAI Service"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): CivitaiService, Build request headers, optionally including Bearer token., Calculate SHA256 hash of a file., Fetch model version info from CivitAI by SHA256 hash (GET). 429/5xx…, POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。 レスポンスの…, Extract relevant fields from CivitAI API response., Batch fetch CivitAI info for multiple model files. Phase 1: SHA256…, Clear cache for a specific hash or all. (+3 more)
|
||||
|
||||
### Community 41 - "Gallery Service Core"
|
||||
Cohesion: 0.15
|
||||
Nodes (8): Path, パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow], 複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。, 選択フォルダ内に新しいサブフォルダを作成する, 画像ファイルを別フォルダへ移動する(複数対応), 画像のPathオブジェクトを返す(ルートで使用), 縮小サムネイルのPathを返す。 ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。…
|
||||
|
||||
### Community 42 - "Eagle Routes Backend"
|
||||
Cohesion: 0.14
|
||||
Nodes (20): _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test(), Application, Request, Response (+12 more)
|
||||
|
||||
### Community 43 - "Style Catalog Tab"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): selectStyleByName(), _wireLabValueFilter(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce() (+12 more)
|
||||
|
||||
### Community 44 - "Image Prompt Tab"
|
||||
Cohesion: 0.21
|
||||
Nodes (20): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+12 more)
|
||||
|
||||
### Community 45 - "SDXL 3D Pose Editor DWPose Workflow"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): CLIP Text Encode Negative Prompt Node, CLIP Text Encode Positive Prompt Node, Inpaint with Mask Editor One (Workflow Screenshot), Load Checkpoint Node, Mask Editor One Node, Save Image Node, VAE Encode (for Inpainting) Node, 3D Pose Editor Node (comfyui-vrm-pose-editor) (+12 more)
|
||||
|
||||
### Community 46 - "Gallery Service Core"
|
||||
Cohesion: 0.10
|
||||
Nodes (7): GalleryService, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。 フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。, outputフォルダのフォルダツリーを返す, ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を 返す。無ければ作成する。, Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を 返す。無ければ作成する。
|
||||
|
||||
### Community 47 - "Image Edit Tab & Mask Editor One"
|
||||
Cohesion: 0.13
|
||||
Nodes (20): Image Edit Tab, Mask Editor One Integration, Mask slot for inpainting (v0.3.71), G'MIC-Qt Standalone, Help Tab, ABR Brush Library (Mask Editor One), Image Edit BG Remove Tool, Image Edit Blur Tool (Whole/Rect Blur & Mosaic) (+12 more)
|
||||
|
||||
### Community 48 - "App Shell & Nodes Tab Ecosystem"
|
||||
Cohesion: 0.13
|
||||
Nodes (19): ComfyUI-Workflow-Studio README, Canvas Snapshot Feature, ComfyUI-Custom-Scripts (external), Node Sets, web/comfyui/node_sets_menu.js, py/routes/nodes_routes.py, py/services/nodes_service.py, Nodes Tab (+11 more)
|
||||
|
||||
### Community 49 - "Node Sets Menu Model Fetch"
|
||||
Cohesion: 0.22
|
||||
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
|
||||
|
||||
### Community 50 - "Image Edit Background Removal"
|
||||
Cohesion: 0.18
|
||||
Nodes (7): comfyUI, comfyEditor, comfyWorkflow, MaskEditorOneBridge, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
|
||||
|
||||
### Community 51 - "ComfyUI Workflow Utilities"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
|
||||
|
||||
### Community 52 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.20
|
||||
Nodes (18): _buildSimpleGroupList(), _getGroupSelCount(), _getGroupSelCountFrom(), initBatchTab(), _loadBatchCheckpointGroups(), _loadBatchLoraGroups(), _loadPromptGroupsForBatch(), _loadWorkflowGroupsForBatch() (+10 more)
|
||||
|
||||
### Community 53 - "Batch Group UI & Security Fixes"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): GenerateUI Batch 4-Type Group Selection (unreleased), Batch Queue Sampler/Scheduler Columns, LoraManager Data Format (__value__ / text), LORA SYNTAX innerHTML Display Bug, Models Batch Group Registration UI (unreleased), Prompt/Workflow Batch Group Registration UI (unreleased), RESERVED_GROUPS Mechanism, SSL CERT_NONE MitM Vulnerability (+9 more)
|
||||
|
||||
### Community 54 - "DEVLOG Early History & Manager Registration"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): comfyui-image-feeder (external node), __Feeder__ reserved group, Feeder Subtab, static/js/feeder-tab.js, web/comfyui/gallery_feeder_extension.js, py/nodes/gallery_feeder_node.py, Gallery mode (Feeder), Help & Support Tab (+9 more)
|
||||
|
||||
### Community 55 - "App Shell & ComfyUI Ecosystem Refs"
|
||||
Cohesion: 0.12
|
||||
Nodes (16): static/js/app.js, ComfyUI, static/js/comfyui-client.js, ComfyUI-Lora-Manager (external), ComfyUI Manager, ComfyUI-Workflow-Studio, py/config.py, data/ (fallback data dir) (+8 more)
|
||||
|
||||
### Community 56 - "GenerateUI Batch & Workflow Editor Core"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): Batch tab (v0.3.18), Bypass/Mute node handling (v0.3.75), static/js/comfyui-editor.js, Create Catalog / Catalog buttons (v0.3.95), Eagle, py/routes/eagle_routes.py, Emphasis weight editing (v0.3.70), Fooocus (external, by lllyasviel) (+8 more)
|
||||
|
||||
### Community 59 - "Tagger Tab & Gallery Bulk Actions"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): prestartup_script.py Early Env Setup, Tagger Tab, static/js/tagger-tab.js, Unsloth Backend (AI TOOL Tab), Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading) (+7 more)
|
||||
|
||||
### Community 60 - "Image Edit Layer Core & Bug Fixes"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): async Event Handler e.currentTarget Null-After-Await Pattern, Draw/Mask Layer Disappearing Bug Fix, G'MIC-Qt Filter Tool Integration, Image Edit Layer Lock Feature, LayerManager.js (Layer Compositing Core), Image Edit Save to Gallery Feature, MaskTool.js, Models Tab Overlay Button Not-Updating Bug Fix (+7 more)
|
||||
|
||||
### Community 61 - "G'MIC Routes"
|
||||
Cohesion: 0.21
|
||||
Nodes (14): _gmic_run_gui(), handle_open(), handle_result(), handle_status(), Application, Request, Response, G'MIC-Qt integration API routes. (+6 more)
|
||||
|
||||
### Community 62 - "Ollama Routes"
|
||||
Cohesion: 0.23
|
||||
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
|
||||
|
||||
### Community 63 - "Gallery Metadata/Service Bridge"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。, OSで使えない文字を除去し、拡張子が無ければ付与する, 任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。 Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。 (+3 more)
|
||||
|
||||
### Community 65 - "Mask Editor One BiRefNet/SAM3 Integration"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): store_image API bg_image_b64 Field Rename Compatibility Fix, BiRefNet BG Remove (Mask Editor One Integration), Mask Editor One (comfyui-mask-editor-one Custom Node), Mask Layer Add/Subtract Composite Mode (toggleOperation), MaskTool.js ABR Brush Support (spacing/angle/jitter), Plan: Image Edit Extension — ABR Brush + Mask Editor One Integration, SAM3 Text-Prompt Segmentation (Mask Editor One Integration), Top Bar Icon Disappearance Fix (MutationObserver, top_menu_extension.js) (+6 more)
|
||||
|
||||
### Community 66 - "Eagle Routes Backend"
|
||||
Cohesion: 0.23
|
||||
Nodes (11): Request, Response, Serve the main SPA page., Serve files from the current workflows directory (dynamic path)., Serve files (index thumbnails) from the Lab plan directory., Main entry point for Workflow Studio plugin., Register all routes with ComfyUI's server., serve_index_page() (+3 more)
|
||||
|
||||
### Community 67 - "Prompts API Routes/Service"
|
||||
Cohesion: 0.25
|
||||
Nodes (13): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+5 more)
|
||||
|
||||
### Community 68 - "Workflow Analyzer"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): analyze_workflow(), _clip_type_from_ui_node(), _collect_all_ui_nodes(), _detect_model_type_from_name(), _model_name_from_api_node(), _model_name_from_ui_node(), Workflow analyzer - detects model types and counts input/output nodes., Analyze workflow JSON and return model types, input/output node counts. (+5 more)
|
||||
|
||||
### Community 69 - "Gallery Tab Core & Feeder Group"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): ComfyUI-Gallery (external, by PanicTitan), Folder tree root label (v0.3.90), py/services/gallery_metadata.py, py/routes/gallery_routes.py, py/services/gallery_service.py, Gallery Tab, static/js/gallery-tab.js, static/js/image-prompt-tab.js (+6 more)
|
||||
|
||||
### Community 70 - "AI TOOL Backend & Model Tab Updates"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): AI_BACKEND_DEFAULT_URLS / getAiBackendDefaultUrl() Helper (util.js), GenerateUI Model Tab Active Highlight Color Feature, ponyxlWildcardsVault Format Live (Un-imported) Support, reasoning_content Field Handling Bug Fix (Unsloth), SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist), AI TOOL Thinking Mode / Max Tokens Setting, Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention), Unsloth Server-Side Proxy (unsloth_routes.py) (+5 more)
|
||||
|
||||
### Community 71 - "Tagger Tab Implementation & Security Fixes"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): Gallery Detail Panel UI Improvement (Tab/Action Row Split), Gallery Tag Save Bug Fix (tags.forEach not a function), Path Traversal Security Fix (Tagger + Gallery), Security Fix: XSS Escaping + Path Validation (2026-06-20), Tagger Batch .txt Output Feature, Tagger Single GenUI:P Button, TaggerService / TaggerDbService (Backend), Tagger Tab New Implementation (+5 more)
|
||||
|
||||
### Community 72 - "Eagle Routes Backend"
|
||||
Cohesion: 0.19
|
||||
Nodes (12): _get_api_key(), handle_proxy(), _is_allowed_base_url(), Application, Request, Response, Unsloth API proxy routes. Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop…, Return the Unsloth API key from the environment (.env), or None. (+4 more)
|
||||
|
||||
### Community 73 - "Prompts API Routes/Service"
|
||||
Cohesion: 0.28
|
||||
Nodes (3): PromptsService, Prompt presets management service., Manages prompt presets (CRUD, categories, favorites).
|
||||
|
||||
### Community 74 - "Metadata & Nodes Tab Core"
|
||||
Cohesion: 0.17
|
||||
Nodes (13): CLIPTextEncodeEditPlus edit+ support (v0.3.92), static/js/comfyui-workflow.js, Metadata Tab, static/js/metadata-tab.js, py/services/png_extractor.py, UI-to-API conversion, py/services/workflow_analyzer.py, Gallery Bulk Actions (Group/Favorite/Compare/Move/Export/Delete) (+5 more)
|
||||
|
||||
### Community 75 - "AI Tool Tab Frontend"
|
||||
Cohesion: 0.26
|
||||
Nodes (12): _applyGenOptions(), callChat(), callLLM(), callVLM(), _clearToolsImage(), fileToBase64(), _formatMessagesForBackend(), initVlmTab() (+4 more)
|
||||
|
||||
### Community 76 - "Node Sets Menu & Sidebar"
|
||||
Cohesion: 0.22
|
||||
Nodes (13): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), injectStyles() (+5 more)
|
||||
|
||||
### Community 77 - "Gallery Feeder Extension (Frontend)"
|
||||
Cohesion: 0.36
|
||||
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
|
||||
|
||||
### Community 78 - "Gallery Speed & Bulk Features"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Background Daemon Thread Indexing Pattern (asyncio.to_thread + Cancelable Scan), Select All Button + Gallery Bulk Bar i18n, Bulk API Endpoint Pattern (Single Request vs N Parallel), v0.3.44 Gallery Image Compare Mode, v0.3.44 Gallery Prompt Full-Text Search, v0.3.44 Gallery Speedup (Server Thumbnails + Infinite Scroll), v0.3.45 Gallery Shift+Click Range Selection, v0.3.48 Gallery Image Download / Bulk ZIP Export (+2 more)
|
||||
|
||||
### Community 79 - "Style Catalog & Batch Loop Core"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Catalog Creation "Don't keep copy in Output" Option, Style/Prompt to ImagePrompt Full Rename, Prompt Tab Style Management Sub-tab (CRUD), Reuse Existing Batch/Selection UI Before Building New, _runBatchLoop() Generic Batch Loop, Backend save_image_to_folder() Generalization, Style Catalog Gallery Sub-tab, trackProgress() Timeout & execution_interrupted Handling Fix (+2 more)
|
||||
|
||||
### Community 80 - "Metadata Tab Node Support History"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): CLIPLoader Widget Name Bug (clip_name1 to clip_name), model-and-prompt-from-metadata Custom Node Pack, Top Bar Icon Not Showing Bug, v0.3.10: Information Tab Canvas Drag Support, v0.3.11: Metadata Tab Node Type Expansion + Settings Text Size, v0.3.27: model-and-prompt-from-metadata Node Support, v0.3.6: Metadata Tab Addition, v0.3.7: Metadata Tab Flux2/Qwen/Z-Image Support (+2 more)
|
||||
|
||||
### Community 82 - "Gallery Feeder Node (Backend)"
|
||||
Cohesion: 0.27
|
||||
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
|
||||
|
||||
### Community 83 - "App Shell & i18n Init"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
|
||||
|
||||
### Community 84 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.24
|
||||
Nodes (6): initGenerateTab(), updateStatus(), highlightJSON(), syncJsonHighlight(), syncScroll(), refreshLabLiveDefaults()
|
||||
|
||||
### Community 87 - "Node Sets Metadata Extraction"
|
||||
Cohesion: 0.36
|
||||
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
|
||||
|
||||
### Community 88 - "Node Sets Prompt Extraction"
|
||||
Cohesion: 0.33
|
||||
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
|
||||
|
||||
### Community 89 - "Inpaint Basic Workflow"
|
||||
Cohesion: 0.53
|
||||
Nodes (9): CLIP Text Encode (Prompt) - negative conditioning, CLIP Text Encode (Prompt) - positive conditioning, KSampler node (ddim sampler, 30 steps, cfg 4.0, denoise 1.00), Load Checkpoint node (hayochamixIDXL_v11+_bl checkpoint), Load Image node (clipspace painted mask input), Save Image node (ComfyUI filename prefix), VAE Decode node, VAE Encode (for Inpainting) node with grow_mask_by (+1 more)
|
||||
|
||||
### Community 90 - "Gallery Feeder & Batch Features"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): __Feeder__ Reserved Group Protection + F Button + Seed Bug Fix, Feeder Tab Gallery Mode (vs Image Loop Mode), Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync), WFS_GalleryFeeder ComfyUI Canvas Controls Extension, WFS_GalleryFeeder Node (Gallery Feeder Feature), Models Tab Thumbnail Update Bug Fix (Cache/Corrupt-File/Disabled-Model), v0.3.28 Batch Tab Sampler/Scheduler Support, v0.3.56 Style Feature (GenerateUI Toolbar & Batch Tab) (+1 more)
|
||||
|
||||
### Community 91 - "Gallery Service Background Indexing"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
|
||||
|
||||
### Community 92 - "Settings/CivitAI Config Resolution"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over…, Merge updates into existing settings., Manages application settings (data/settings.json)., SettingsService
|
||||
|
||||
### Community 93 - "Gallery Service Vault Helpers"
|
||||
Cohesion: 0.22
|
||||
Nodes (4): 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…, 画像と同名の.txtサイドカーにプロンプトテキストを保存する
|
||||
|
||||
### Community 94 - "GenerateUI Lab Tab"
|
||||
Cohesion: 0.36
|
||||
Nodes (9): _coreGenerate(), _expandWildcardsInWorkflow(), _annotatedImageRef(), _maybeSaveIndexImageOnRun(), _runLabBatch(), _setRunUiState(), _waitIfPaused(), getEagleSettings() (+1 more)
|
||||
|
||||
### Community 95 - "Style Prompt Seed Import Tool"
|
||||
Cohesion: 0.36
|
||||
Nodes (8): clean_tags(), import_category(), main(), parse_yaml_leaves(), Path, Style/Prompt ギャラリー用シードデータ投入スクリプト。 comfyui_prompt_gallery / prompt_builder_proto…, comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。 「key:」行の直後が「-…, 1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)
|
||||
|
||||
### Community 96 - "FireRed 3D Pose Image Edit Workflow"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): 3D Pose Editor node, FireRed-Image-Edit-1.1_fp8mixed_comfy.safetensors, FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors (LoRA), Image Edit (FireRed Image Edit 1.1) node, qwen_2.5_vl_7b_fp8_scaled.safetensors (CLIP), qwen_image_vae.safetensors (VAE), Save Image node, 3D Pose Editor to Image Edit (Fire Red) Workflow
|
||||
|
||||
### Community 97 - "Chat Image Generation & Inpaint Bridge"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): comfyEditor.applyImageToSlot() Method, "Use Dedicated Workflow" Toggle Pattern (Inpaint/Chat Image Gen Shared Design), generate_image Tool Call (LLM Tool Calling to ComfyUI Generation), tagger_settings.json Missing from Data Export Bug Fix, v0.3.62 Send GenUI Image Button + tagger_settings.json Export Fix, v0.3.71 Inpaint Feature (GenerateUI Image Tab + Image Edit Inpaint Tool), v0.3.73 AI TOOL Chat Tool Calling Image Generation + Checkpoint Safety, v0.3.74 AI TOOL Chat Image Attachment + I2I Bridge
|
||||
|
||||
### Community 98 - "Subgraph Workflow Support & Fixes"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): detectFormat() App vs UI Judgment Criteria Fix (linearMode), DynamicCombo (COMFY_DYNAMICCOMBO_V3) Flat Dot-Key Input Fix, _flattenSubgraphs() Outer Widget Value Injection Into Inner Nodes, Subgraph Fan-out Fix: inputPortTargets Array + _wfmLiteralInputs, v0.3.78 GenerateUI/Metadata Tab Subgraph Workflow Support (Mage-Flow), v0.3.84 Ernie T2I "source" Validation Error / Subgraph Fan-out Bug, workflow_analyzer.py P:/I:/→ Count and Format Badge Fix, Workflow Tab Summarize: Structured Data Instead of Raw JSON Truncation
|
||||
|
||||
### Community 99 - "KREA-2 & Lab Tab Extensions"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): DynamicCombo Multi-Slot Consumption Bug (sampling_mode), GenerateUI Input Image Placeholder Feature (Color/Image mode), KREA-2 Turbo Workflow Support, Lab Tab Bypass Checkbox (Per-Keyframe Row Disable), Lab Tab Clear / Get from Previous Buttons, Lab Tab Keyframe #1 Live Default Reflection, Role Propagation Bug: PreviewAny / StringConcatenate Relay Nodes, v0.3.97 KREA-2 Workflow Support + Lab Tab Extension + Input Image Placeholder
|
||||
|
||||
### Community 100 - "LoRA Pane Evolution & Help Tab"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): GenerateUI Reset Workflow Button + Input Tab Tabify (2026-06-08), Help Tab Search Feature + Font Enlargement + Gallery-mode i18n, Help Tab Sidebar Layout (2-Column), v0.3.29 LoRA Section Integration (Single+Stack Merge), v0.3.30 LoRA Stack Trigger Words Bug Fix, v0.3.31 LoRA Pane Single/Stack Tab Split, v0.3.32 Lora Loader (LoraManager) LoRA Detection Support, v0.3.33 LoRA Pane / Workflow Parse Bug Fixes
|
||||
|
||||
### Community 103 - "App Shell & i18n Init"
|
||||
Cohesion: 0.43
|
||||
Nodes (7): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), _joinBytes(), _n2b(), _pngCrc32()
|
||||
|
||||
### Community 104 - "DWPose Image Feeder Workflow"
|
||||
Cohesion: 0.52
|
||||
Nodes (7): DWPose Image Feeder Workflow, Apply ControlNet Node, DWPose Estimator Node (comfyui-controlnet-aux), Image Feeder Node (comfyui-image-feeder), KSampler Node, Load Checkpoint Node (animagineXLRealistic_v6), Load ControlNet Model Node (OpenPoseXL2)
|
||||
|
||||
### Community 105 - "AI Tab & RAW JSON Search History"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): AI TOOL Tab English Localization, RAW JSON Panel Search Feature, v0.3.12: README Screenshot Refresh + workflow_analyzer Extension, v0.3.13: AI Tab (A) Addition, v0.3.14: AI TOOL Tab 3-Pane Redesign + RAW JSON Colors, v0.3.15: Sample Workflow Bundling + CivitAI Auto Preview, v0.3.16: CivitAI Preview Fallback Display
|
||||
|
||||
### Community 106 - "Prompt Extraction Bug Fixes"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): ComfySwitchNode Branch Resolution (Prompt Enhancement Toggle), GenerateUI vs Metadata Tab: Separate Non-Synced Analysis Logic, node_sets_menu.js Local Duplication Pattern (web/comfyui/ import restriction), v0.3.79 Side Panel I-Tab Mage-Flow Subgraph Support, v0.3.82 Metadata Tab / Side Panel I-Tab Prompt Extraction Bug (LongCat/Boogu/HiDream E1/FireRed), v0.3.83 Ernie Image Support + Link-Sourced Prompt Pattern Fix, WFS_PromptText Node GenerateUI Prompt Tab Support + Drop Title Fix
|
||||
|
||||
### Community 107 - "Comic Creator Bridge & LoRA Batch Fixes"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): Development Repo vs Runtime custom_nodes Folder Sync Reminder, _wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS), LoRA Batch Path Separator Mismatch Fix + resolveLoraName(), LoRA Batch Execution Prompt Auto-Reflection + Restore, Prompt Weight Ctrl+Up/Down Editing (A1111-style), v0.3.69 Gallery to Comic Creator "Send CC" Button, v0.3.70 Ollama 404 Fix + Prompt Revert Button + Weight Ctrl+Arrow Editing
|
||||
|
||||
### Community 108 - "Settings/CivitAI Config Resolution"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): _get_ssl_context(), _make_ssl_context(), CivitAI API integration service., Return an SSL context with CA verification. 1. certifi CA bundle (available in…, Download an image from URL and save to save_path. Returns True on success.
|
||||
|
||||
### Community 109 - "Node Sets Menu Model Fetch"
|
||||
Cohesion: 0.57
|
||||
Nodes (7): createModelItem(), fetchModelList(), matchesModelSearch(), renderModelAll(), renderModelByType(), renderModelFavorites(), renderModelGroups()
|
||||
|
||||
### Community 110 - "G'MIC Integration & Security Fixes"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): Draw/Mask Brush Circular Cursor + Off-canvas Drag Support, gmic_qt.exe Argument Order Fix (-o output input), handle_result Path Traversal Fix (gmic/result endpoint), gmic_routes.py (New Backend for G'MIC-Qt Job Handling), v0.3.60 Image Edit Filter Tool (G'MIC-Qt Integration), v0.3.61 G'MIC Bug Fix + Security + Draw/Mask Brush Operability
|
||||
|
||||
### Community 111 - "Comic Creator Integration Bridges"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): ComfyUI Comic Creator (External iframe-embedded Custom Node), _runInpaintWithImages() / runInpaintExternal() Shared Inpaint Logic, v0.3.72 Comic Creator External Inpaint Entry Point, v0.3.76 Comic Creator I2I Run Bridge (_wfmReceiveI2IRunRequest), v0.3.80 Comic Creator Semi-auto Manga Bridge (LLM Prompt Draft + Batch Gen), v0.3.85 Generate UI Bridge (T2I) Default Workflow + Negative Prompt
|
||||
|
||||
### Community 112 - "GenerateUI Model Tab GGUF/Embedding Support"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): GenUI Model LoRA Reset-on-Subtab-Switch Bug Fix, v0.3.51 Embedding GenUI PP/NP Support, v0.3.51 GenerateUI Hypernetwork Support, v0.3.51 Models Tab Side Panel GenUI Model Button, Hypernetwork/Embedding Not-Displayed Bug (V3 object_info Format), v0.3.58 GenerateUI GGUF Diffusion Model / Text Encoder Support
|
||||
|
||||
### Community 113 - "Bypass Node Handling & Advanced Sampling"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): GenerateUI Raw JSON Bypass/Mute Node Indicator, Reroute Node missing_node_type 400 Error Fix (_resolveBypassSource extension), _resolveBypassSource() Bypass Pass-through Link Resolution, _simulateWidgetValues / _stripLegacyLinkedWidgetValues Common Logic Rewrite, v0.3.75 Bypass (mode:4) Node Workflow Generation Failure Fix, v0.3.81 Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Advanced Sampling Support
|
||||
|
||||
### Community 114 - "Prompt Text Node"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
|
||||
|
||||
### Community 116 - "Gallery Service Vault Helpers"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)
|
||||
|
||||
### Community 117 - "PNG Metadata Extractor"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): extract_png_workflow(), Extract ComfyUI workflow JSON from PNG metadata., Extract ComfyUI workflow JSON from PNG byte data. Returns dict or None., Workflow CRUD and metadata management service.
|
||||
|
||||
### Community 118 - "AI Skills & Help Redesign"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): AI Skills Mechanism (Selectable System Prompt Library), Help Tab Card-Format Redesign (16 Pages), Skill Creator (Interactive Skill Creation Meta-skill), SVG Icon Generator Skill (Shape Decomposition Prompting), v0.3.79 AI TOOL Chat Skills Mechanism + SVG Generation Quality Improvement
|
||||
|
||||
### Community 119 - "Prompt Extraction Bug Fixes"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Checkpoint Silent Substitution Bug (getLastCheckpointSubstitutions), CLIPTextEncodeEditPlus Metadata Extraction Gap, convertUiToApi forceInput Widget Misclassification (Root Cause), LoRA Stack: Use Stack Group Checkbox, v0.3.92 convertUiToApi forceInput Bug + LoRA Stack Improvements
|
||||
|
||||
### Community 120 - "AI TOOL Chat & Wildcard Features"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): v0.3.41 ImpactWildcardEncode/Processor Prompt Detection Bug Fix, v0.3.40 AI TOOL Tab Chat Pane, v0.3.40 AI TOOL Wildcard Generation Feature, v0.3.46 Prompt Tab AI Assistant LM Studio Support, v0.3.49 A1111-style Wildcard Expansion in GenerateUI
|
||||
|
||||
### Community 121 - "Config & Service Modules"
|
||||
Cohesion: 0.40
|
||||
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
|
||||
|
||||
### Community 122 - "Group Feature Path Bug Fixes"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): Windows Path Separator Normalization Pattern, v0.3.45 Group Orphan Entry Auto Cleanup, v0.3.47 Models Tab File-Move Group State Fix, v0.3.55 Group Feature Bug Fixes (All Tabs)
|
||||
|
||||
### Community 123 - "SVG Generation & Display Support"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): AI TOOL Chat Workflow-less SVG Generation/Display/Gallery Save, v0.3.77 Gallery Tab SVG File Display/Management Support, v0.3.78 Eagle Auto-Save SVG Support
|
||||
|
||||
### Community 124 - "Send to Canvas Feature"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): API-Format Workflow Loads Empty on Canvas Bug Fix, Send to Canvas Feature (Workflow Tab / Gallery Tab Title DnD), Send to Canvas via window.opener Direct Load
|
||||
|
||||
### Community 125 - "Module Split Refactor (v0.4.0)"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Callback Injection Pattern (One-way Dependency), Graphify Community Cohesion Analysis, v0.4.0 image-edit-tab.js / models-tab.js Low-Cohesion Module Split
|
||||
|
||||
### Community 126 - "CivitAI Detail Panel History"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): CivitAI modelUrl Broken Fallback Bug, v0.3.20: CivitAI Detail Panel Enhancement, v0.3.21: CivitAI Host Setting + Sample Subtab
|
||||
|
||||
### Community 127 - "GenerateUI Layout & JSON Highlight"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): JSONシンタックスハイライト, Raw JSON同期修正, v0.2.4 GenerateUIタブ レイアウト再設計
|
||||
|
||||
### Community 128 - "Workflow Tab Bug Fixes"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): v0.3.23: Two Bug Fixes, Workflow Batch 404 Endpoint Bug, Workflow Rename Duplicate Listener Bug
|
||||
|
||||
## Ambiguous Edges - Review These
|
||||
- `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` → `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: references
|
||||
- `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` → `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: semantically_similar_to
|
||||
- `Models Tab Overlay Button Not-Updating Bug Fix` → `v0.3.54 Image Edit Shape Tool` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: conceptually_related_to
|
||||
- `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` → `v0.3.28 Batch Tab Sampler/Scheduler Support` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: conceptually_related_to
|
||||
|
||||
## Knowledge Gaps
|
||||
- **311 isolated node(s):** `AI_BACKEND_DEFAULT_URLS`, `state`, `LANGUAGE_OPTIONS`, `LANGUAGES`, `SUMMARY_LANGUAGE_OPTIONS` (+306 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **52 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **What is the exact relationship between `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` and `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()`?**
|
||||
_Edge tagged AMBIGUOUS (relation: references) - confidence is low._
|
||||
- **What is the exact relationship between `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` and `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)`?**
|
||||
_Edge tagged AMBIGUOUS (relation: semantically_similar_to) - confidence is low._
|
||||
- **What is the exact relationship between `Models Tab Overlay Button Not-Updating Bug Fix` and `v0.3.54 Image Edit Shape Tool`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **What is the exact relationship between `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` and `v0.3.28 Batch Tab Sampler/Scheduler Support`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **Why does `showToast()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `Image Edit Layer & Draw Tools`, `GenerateUI Feeder Tab`, `App Shell & i18n Init`, `Nodes Tab Frontend`, `GenerateUI Lab Tab`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `Workflow Tab Frontend & Storage`, `Image Edit Layer Manager Core`, `Style Catalog Tab`, `Image Prompt Tab`, `Image Edit Background Removal`, `Image Edit Blur Tool (BlurTool.js)`, `AI Tool Tab Frontend`, `Image Edit Background Removal (BgRemove.js)`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Image Edit SAM3 Segmentation`, `GenerateUI Lab Tab`, `Image Edit File Export (FileExport.js)`, `Image Edit G'MIC Integration`?**
|
||||
_High betweenness centrality (0.042) - this node is a cross-community bridge._
|
||||
- **Why does `t()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `GenerateUI Feeder Tab`, `AI Tool Tab Frontend`, `App Shell & i18n Init`, `Image Prompt Tab`, `GenerateUI Lab Tab`, `Style Catalog Tab`, `Metadata Tab Frontend`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Nodes Tab Frontend`, `Workflow Tab Frontend & Storage`, `ComfyUI Client & Editor`, `GenerateUI Lab Tab`?**
|
||||
_High betweenness centrality (0.023) - this node is a cross-community bridge._
|
||||
- **Why does `GalleryService` connect `Gallery Service Core` to `Gallery API Routes`, `Gallery Service Core`, `Gallery Metadata/Service Bridge`, `Gallery Service Vault Helpers`, `Gallery Service Background Indexing`, `Gallery Service Vault Helpers`, `Gallery Metadata Store`, `Gallery Metadata/Service Bridge`?**
|
||||
_High betweenness centrality (0.018) - this node is a cross-community bridge._
|
||||
File diff suppressed because one or more lines are too long
+91986
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,842 @@
|
||||
{
|
||||
"__init__.py": {
|
||||
"mtime": 1781939762.254596,
|
||||
"seen": 1787194338.8008406,
|
||||
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
|
||||
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
|
||||
},
|
||||
"prestartup_script.py": {
|
||||
"mtime": 1787036267.332648,
|
||||
"seen": 1787194338.800846,
|
||||
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
|
||||
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
|
||||
},
|
||||
"py/__init__.py": {
|
||||
"mtime": 1773797715.629029,
|
||||
"seen": 1787194338.8008478,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/config.py": {
|
||||
"mtime": 1786245317.3352194,
|
||||
"seen": 1787194338.800849,
|
||||
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
|
||||
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
|
||||
},
|
||||
"py/nodes/__init__.py": {
|
||||
"mtime": 1774423045.6646452,
|
||||
"seen": 1787194338.8008502,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/nodes/gallery_feeder_node.py": {
|
||||
"mtime": 1781943269.5205867,
|
||||
"seen": 1787194338.8008513,
|
||||
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
|
||||
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
|
||||
},
|
||||
"py/nodes/prompt_text.py": {
|
||||
"mtime": 1774423045.6669831,
|
||||
"seen": 1787194338.8008525,
|
||||
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
|
||||
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
|
||||
},
|
||||
"py/routes/__init__.py": {
|
||||
"mtime": 1773797716.663036,
|
||||
"seen": 1787194338.8008535,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/routes/eagle_routes.py": {
|
||||
"mtime": 1785464911.9023821,
|
||||
"seen": 1787194338.800855,
|
||||
"ast_hash": "edd32039935a892317c55b434c9839ab",
|
||||
"semantic_hash": "edd32039935a892317c55b434c9839ab"
|
||||
},
|
||||
"py/routes/gallery_routes.py": {
|
||||
"mtime": 1786682156.6899943,
|
||||
"seen": 1787194338.800856,
|
||||
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
|
||||
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
|
||||
},
|
||||
"py/routes/gmic_routes.py": {
|
||||
"mtime": 1782544784.03979,
|
||||
"seen": 1787194338.8008568,
|
||||
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
|
||||
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
|
||||
},
|
||||
"py/routes/lab_routes.py": {
|
||||
"mtime": 1786322410.571655,
|
||||
"seen": 1787194338.8008578,
|
||||
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
|
||||
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
|
||||
},
|
||||
"py/routes/models_routes.py": {
|
||||
"mtime": 1782289767.1073005,
|
||||
"seen": 1787194338.8008592,
|
||||
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
|
||||
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
|
||||
},
|
||||
"py/routes/nodes_routes.py": {
|
||||
"mtime": 1774255599.0841086,
|
||||
"seen": 1787194338.8008602,
|
||||
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
|
||||
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
|
||||
},
|
||||
"py/routes/ollama_routes.py": {
|
||||
"mtime": 1784126582.1613884,
|
||||
"seen": 1787194338.800861,
|
||||
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
|
||||
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
|
||||
},
|
||||
"py/routes/prompts_routes.py": {
|
||||
"mtime": 1774423045.6696556,
|
||||
"seen": 1787194338.8008618,
|
||||
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
|
||||
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
|
||||
},
|
||||
"py/routes/settings_routes.py": {
|
||||
"mtime": 1786681377.025748,
|
||||
"seen": 1787194338.8008628,
|
||||
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
|
||||
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
|
||||
},
|
||||
"py/routes/skill_routes.py": {
|
||||
"mtime": 1785634791.033795,
|
||||
"seen": 1787194338.800864,
|
||||
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
|
||||
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
|
||||
},
|
||||
"py/routes/tagger_routes.py": {
|
||||
"mtime": 1781503496.8879485,
|
||||
"seen": 1787194338.8008647,
|
||||
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
|
||||
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
|
||||
},
|
||||
"py/routes/unsloth_routes.py": {
|
||||
"mtime": 1787036726.267709,
|
||||
"seen": 1787194338.8008657,
|
||||
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
|
||||
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
|
||||
},
|
||||
"py/routes/wildcard_routes.py": {
|
||||
"mtime": 1777370036.4121041,
|
||||
"seen": 1787194338.8008666,
|
||||
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
|
||||
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
|
||||
},
|
||||
"py/routes/workflow_routes.py": {
|
||||
"mtime": 1773896239.095392,
|
||||
"seen": 1787194338.8008673,
|
||||
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
|
||||
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
|
||||
},
|
||||
"py/services/__init__.py": {
|
||||
"mtime": 1773797717.739297,
|
||||
"seen": 1787194338.800868,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/services/civitai_service.py": {
|
||||
"mtime": 1780233122.7587051,
|
||||
"seen": 1787194338.8008692,
|
||||
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
|
||||
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
|
||||
},
|
||||
"py/services/gallery_metadata.py": {
|
||||
"mtime": 1786170828.9941547,
|
||||
"seen": 1787194338.80087,
|
||||
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
|
||||
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
|
||||
},
|
||||
"py/services/gallery_service.py": {
|
||||
"mtime": 1786638406.9058151,
|
||||
"seen": 1787194338.800871,
|
||||
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
|
||||
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
|
||||
},
|
||||
"py/services/lab_plan_service.py": {
|
||||
"mtime": 1786322396.0192728,
|
||||
"seen": 1787194338.8008716,
|
||||
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
|
||||
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
|
||||
},
|
||||
"py/services/models_service.py": {
|
||||
"mtime": 1782393318.5989842,
|
||||
"seen": 1787194338.8008728,
|
||||
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
|
||||
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
|
||||
},
|
||||
"py/services/nodes_service.py": {
|
||||
"mtime": 1774255599.0862236,
|
||||
"seen": 1787194338.8008735,
|
||||
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
|
||||
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
|
||||
},
|
||||
"py/services/png_extractor.py": {
|
||||
"mtime": 1773797967.5810304,
|
||||
"seen": 1787194338.8008747,
|
||||
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
|
||||
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
|
||||
},
|
||||
"py/services/prompts_service.py": {
|
||||
"mtime": 1774423045.6711988,
|
||||
"seen": 1787194338.8008757,
|
||||
"ast_hash": "79a98e220d2b556106e8530054547155",
|
||||
"semantic_hash": "79a98e220d2b556106e8530054547155"
|
||||
},
|
||||
"py/services/settings_service.py": {
|
||||
"mtime": 1773798498.9270506,
|
||||
"seen": 1787194338.8008769,
|
||||
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
|
||||
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
|
||||
},
|
||||
"py/services/skill_service.py": {
|
||||
"mtime": 1785637306.8654232,
|
||||
"seen": 1787194338.8008778,
|
||||
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
|
||||
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
|
||||
},
|
||||
"py/services/tagger_db_service.py": {
|
||||
"mtime": 1781503496.8949444,
|
||||
"seen": 1787194338.8008785,
|
||||
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
|
||||
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
|
||||
},
|
||||
"py/services/tagger_service.py": {
|
||||
"mtime": 1781503496.8969479,
|
||||
"seen": 1787194338.8008797,
|
||||
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
|
||||
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
|
||||
},
|
||||
"py/services/wildcard_service.py": {
|
||||
"mtime": 1777380000.0054882,
|
||||
"seen": 1787194338.8008807,
|
||||
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
|
||||
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
|
||||
},
|
||||
"py/services/workflow_analyzer.py": {
|
||||
"mtime": 1785474766.4736228,
|
||||
"seen": 1787194338.8008814,
|
||||
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
|
||||
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
|
||||
},
|
||||
"py/services/workflow_service.py": {
|
||||
"mtime": 1775439760.4805496,
|
||||
"seen": 1787194338.8008823,
|
||||
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
|
||||
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
|
||||
},
|
||||
"py/wfm.py": {
|
||||
"mtime": 1787036294.0749958,
|
||||
"seen": 1787194338.8008835,
|
||||
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
|
||||
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"mtime": 1787193198.5117335,
|
||||
"seen": 1787194338.8008842,
|
||||
"ast_hash": "de0f37033a118144dc86368704dc7a34",
|
||||
"semantic_hash": "de0f37033a118144dc86368704dc7a34"
|
||||
},
|
||||
"static/js/ai-tab.js": {
|
||||
"mtime": 1787041206.037751,
|
||||
"seen": 1787194338.8008852,
|
||||
"ast_hash": "4103e9c646b3011bf5ce42d77487497c",
|
||||
"semantic_hash": "4103e9c646b3011bf5ce42d77487497c"
|
||||
},
|
||||
"static/js/app.js": {
|
||||
"mtime": 1787192745.6783228,
|
||||
"seen": 1787194338.8008862,
|
||||
"ast_hash": "b73d44820900bb125886ea51cf879475",
|
||||
"semantic_hash": "b73d44820900bb125886ea51cf879475"
|
||||
},
|
||||
"static/js/comfyui-client.js": {
|
||||
"mtime": 1786676208.2330563,
|
||||
"seen": 1787194338.8008873,
|
||||
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
|
||||
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
|
||||
},
|
||||
"static/js/comfyui-editor.js": {
|
||||
"mtime": 1786975595.7088158,
|
||||
"seen": 1787194338.800888,
|
||||
"ast_hash": "2e378bbfd83ae94e3e068b48bec8676c",
|
||||
"semantic_hash": "2e378bbfd83ae94e3e068b48bec8676c"
|
||||
},
|
||||
"static/js/comfyui-workflow.js": {
|
||||
"mtime": 1786930771.5730827,
|
||||
"seen": 1787194338.8008888,
|
||||
"ast_hash": "c5f980df6dddaa9e5374d7ddc2643098",
|
||||
"semantic_hash": "c5f980df6dddaa9e5374d7ddc2643098"
|
||||
},
|
||||
"static/js/feeder-tab.js": {
|
||||
"mtime": 1781939989.0796032,
|
||||
"seen": 1787194338.80089,
|
||||
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
|
||||
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
|
||||
},
|
||||
"static/js/gallery-tab.js": {
|
||||
"mtime": 1786638406.9234293,
|
||||
"seen": 1787194338.800891,
|
||||
"ast_hash": "a5469191f6f41638aa43decce8ee252c",
|
||||
"semantic_hash": "a5469191f6f41638aa43decce8ee252c"
|
||||
},
|
||||
"static/js/generate-tab.js": {
|
||||
"mtime": 1786932592.3725004,
|
||||
"seen": 1787194338.8008919,
|
||||
"ast_hash": "58f05ad49f87dfd8cff67290e739c3a8",
|
||||
"semantic_hash": "58f05ad49f87dfd8cff67290e739c3a8"
|
||||
},
|
||||
"static/js/i18n.js": {
|
||||
"mtime": 1787192763.662967,
|
||||
"seen": 1787194338.8008926,
|
||||
"ast_hash": "b626aca2093efe87d899d57bc02ce80d",
|
||||
"semantic_hash": "b626aca2093efe87d899d57bc02ce80d"
|
||||
},
|
||||
"static/js/image-edit-tab.js": {
|
||||
"mtime": 1787192013.6171837,
|
||||
"seen": 1787194338.8008935,
|
||||
"ast_hash": "bda888263a8f3aa325c0da5b688d75cb",
|
||||
"semantic_hash": "bda888263a8f3aa325c0da5b688d75cb"
|
||||
},
|
||||
"static/js/image-edit/DrawTool.js": {
|
||||
"mtime": 1782717315.6436062,
|
||||
"seen": 1787194338.8008964,
|
||||
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
|
||||
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
|
||||
},
|
||||
"static/js/image-edit/LayerManager.js": {
|
||||
"mtime": 1782717943.772222,
|
||||
"seen": 1787194338.8009,
|
||||
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
|
||||
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneTools.js": {
|
||||
"mtime": 1782719665.7021136,
|
||||
"seen": 1787194338.8009021,
|
||||
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
|
||||
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
|
||||
},
|
||||
"static/js/image-edit/MaskTool.js": {
|
||||
"mtime": 1782699721.4711535,
|
||||
"seen": 1787194338.8009028,
|
||||
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
|
||||
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
|
||||
},
|
||||
"static/js/image-edit/SelectTool.js": {
|
||||
"mtime": 1782368193.2572634,
|
||||
"seen": 1787194338.800905,
|
||||
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
|
||||
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
|
||||
},
|
||||
"static/js/image-edit/ShapeTool.js": {
|
||||
"mtime": 1782375200.0253434,
|
||||
"seen": 1787194338.800906,
|
||||
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
|
||||
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
|
||||
},
|
||||
"static/js/image-edit/TextTool.js": {
|
||||
"mtime": 1782368193.2592669,
|
||||
"seen": 1787194338.800907,
|
||||
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
|
||||
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
|
||||
},
|
||||
"static/js/image-prompt-tab.js": {
|
||||
"mtime": 1786638406.9362252,
|
||||
"seen": 1787194338.8009079,
|
||||
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
|
||||
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
|
||||
},
|
||||
"static/js/json-highlight.js": {
|
||||
"mtime": 1773834925.1977189,
|
||||
"seen": 1787194338.8009088,
|
||||
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
|
||||
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
|
||||
},
|
||||
"static/js/lab-tab.js": {
|
||||
"mtime": 1786933679.1300104,
|
||||
"seen": 1787194338.80091,
|
||||
"ast_hash": "f04c1d830856c01cc03945857d39bd9b",
|
||||
"semantic_hash": "f04c1d830856c01cc03945857d39bd9b"
|
||||
},
|
||||
"static/js/metadata-tab.js": {
|
||||
"mtime": 1786929497.4063816,
|
||||
"seen": 1787194338.800911,
|
||||
"ast_hash": "989f56e4b0eff94923fc73c75a6ce756",
|
||||
"semantic_hash": "989f56e4b0eff94923fc73c75a6ce756"
|
||||
},
|
||||
"static/js/models-tab.js": {
|
||||
"mtime": 1787145102.2314196,
|
||||
"seen": 1787194338.8009121,
|
||||
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
|
||||
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
|
||||
},
|
||||
"static/js/nodes-tab.js": {
|
||||
"mtime": 1782619197.3828287,
|
||||
"seen": 1787194338.8009202,
|
||||
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
|
||||
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
|
||||
},
|
||||
"static/js/prompt-tab.js": {
|
||||
"mtime": 1786681377.2302153,
|
||||
"seen": 1787194338.8009212,
|
||||
"ast_hash": "9c66fbb4c615e61c3b3ef1220ab20253",
|
||||
"semantic_hash": "9c66fbb4c615e61c3b3ef1220ab20253"
|
||||
},
|
||||
"static/js/settings-tab.js": {
|
||||
"mtime": 1786975595.7637193,
|
||||
"seen": 1787194338.8009222,
|
||||
"ast_hash": "b88c87057ac42e7cbca22cdf1dc522f7",
|
||||
"semantic_hash": "b88c87057ac42e7cbca22cdf1dc522f7"
|
||||
},
|
||||
"static/js/style-catalog-tab.js": {
|
||||
"mtime": 1786678976.6511285,
|
||||
"seen": 1787194338.8009229,
|
||||
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
|
||||
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
|
||||
},
|
||||
"static/js/tagger-tab.js": {
|
||||
"mtime": 1781503496.933307,
|
||||
"seen": 1787194338.8009238,
|
||||
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
|
||||
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
|
||||
},
|
||||
"static/js/util.js": {
|
||||
"mtime": 1787036300.7369778,
|
||||
"seen": 1787194338.8009248,
|
||||
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
|
||||
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
|
||||
},
|
||||
"static/js/workflow-tab.js": {
|
||||
"mtime": 1785474064.011484,
|
||||
"seen": 1787194338.8009257,
|
||||
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
|
||||
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
|
||||
},
|
||||
"tools/import_style_prompt_seed.py": {
|
||||
"mtime": 1786616195.405381,
|
||||
"seen": 1787194338.8009267,
|
||||
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
|
||||
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
|
||||
},
|
||||
"web/comfyui/gallery_feeder_extension.js": {
|
||||
"mtime": 1781943835.232655,
|
||||
"seen": 1787194338.8009274,
|
||||
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
|
||||
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
|
||||
},
|
||||
"web/comfyui/node_sets_menu.js": {
|
||||
"mtime": 1787192023.3791485,
|
||||
"seen": 1787194338.8009284,
|
||||
"ast_hash": "81c0d1363ee69d952ad16a294c6e8a8d",
|
||||
"semantic_hash": "81c0d1363ee69d952ad16a294c6e8a8d"
|
||||
},
|
||||
"web/comfyui/top_menu_extension.js": {
|
||||
"mtime": 1782696309.9055538,
|
||||
"seen": 1787194338.8009293,
|
||||
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
|
||||
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
|
||||
"mtime": 1779256606.5720088,
|
||||
"seen": 1787194338.8009303,
|
||||
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
|
||||
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
|
||||
"mtime": 1779257129.3572881,
|
||||
"seen": 1787194338.8009312,
|
||||
"ast_hash": "8260ca467693c21c497f98113abf37f9",
|
||||
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
|
||||
"mtime": 1779257864.2496417,
|
||||
"seen": 1787194338.8009322,
|
||||
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
|
||||
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.json": {
|
||||
"mtime": 1779255324.1402953,
|
||||
"seen": 1787194338.8009331,
|
||||
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
|
||||
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.json": {
|
||||
"mtime": 1784801838.7836998,
|
||||
"seen": 1787194338.8009338,
|
||||
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
|
||||
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.json": {
|
||||
"mtime": 1784801946.0336668,
|
||||
"seen": 1787194338.800935,
|
||||
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
|
||||
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.json": {
|
||||
"mtime": 1779253098.1947012,
|
||||
"seen": 1787194338.800936,
|
||||
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
|
||||
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
|
||||
"mtime": 1779256158.4671156,
|
||||
"seen": 1787194338.800937,
|
||||
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
|
||||
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
|
||||
"mtime": 1779255768.6302803,
|
||||
"seen": 1787194338.800938,
|
||||
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
|
||||
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.json": {
|
||||
"mtime": 1779255037.8698418,
|
||||
"seen": 1787194338.800939,
|
||||
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
|
||||
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.json": {
|
||||
"mtime": 1779254360.9712114,
|
||||
"seen": 1787194338.8009398,
|
||||
"ast_hash": "463fca53dd0a4030555ff639661f2772",
|
||||
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.json": {
|
||||
"mtime": 1779253668.0067616,
|
||||
"seen": 1787194338.8009408,
|
||||
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
|
||||
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.json": {
|
||||
"mtime": 1779254714.0149894,
|
||||
"seen": 1787194338.8009415,
|
||||
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
|
||||
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.json": {
|
||||
"mtime": 1779253180.3940487,
|
||||
"seen": 1787194338.8009427,
|
||||
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
|
||||
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.json": {
|
||||
"mtime": 1779253367.7975926,
|
||||
"seen": 1787194338.8009434,
|
||||
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
|
||||
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
|
||||
},
|
||||
"templates/index.html": {
|
||||
"mtime": 1787192740.1538603,
|
||||
"seen": 1787194338.8009465,
|
||||
"ast_hash": "0b51d18f3fa91d4be929d72a499e669f",
|
||||
"semantic_hash": "0b51d18f3fa91d4be929d72a499e669f"
|
||||
},
|
||||
"assets/banner.svg": {
|
||||
"mtime": 1776081443.419675,
|
||||
"seen": 1787114579.030294,
|
||||
"ast_hash": "059ec317eaba9e353d220d883477927a",
|
||||
"semantic_hash": "059ec317eaba9e353d220d883477927a"
|
||||
},
|
||||
"assets/icon.svg": {
|
||||
"mtime": 1776081443.4219253,
|
||||
"seen": 1787114579.030295,
|
||||
"ast_hash": "9689aa0178236c19d5240250e08a303e",
|
||||
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
|
||||
},
|
||||
"docs/10_GenUI_Batch.png": {
|
||||
"mtime": 1781057457.561953,
|
||||
"seen": 1787114579.0302958,
|
||||
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
|
||||
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
|
||||
},
|
||||
"docs/11_multiple_select_menu.png": {
|
||||
"mtime": 1781057528.0140572,
|
||||
"seen": 1787114579.0302966,
|
||||
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
|
||||
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
|
||||
},
|
||||
"docs/12_Tagger.png": {
|
||||
"mtime": 1782551031.7107866,
|
||||
"seen": 1787114579.030297,
|
||||
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
|
||||
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
|
||||
},
|
||||
"docs/13_ImageEdit.png": {
|
||||
"mtime": 1782550946.6263509,
|
||||
"seen": 1787114579.030298,
|
||||
"ast_hash": "25a313806ba3c2396c9361b42599b063",
|
||||
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
|
||||
},
|
||||
"docs/1_workflowtab.png": {
|
||||
"mtime": 1779078100.5980048,
|
||||
"seen": 1787114579.0302987,
|
||||
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
|
||||
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
|
||||
},
|
||||
"docs/2_modelstab.png": {
|
||||
"mtime": 1779077971.8302422,
|
||||
"seen": 1787114579.0302994,
|
||||
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
|
||||
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
|
||||
},
|
||||
"docs/3_PromptInputAssistance.png": {
|
||||
"mtime": 1779077804.0445502,
|
||||
"seen": 1787114579.0303004,
|
||||
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
|
||||
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
|
||||
},
|
||||
"docs/4_GenUI_feeder.png": {
|
||||
"mtime": 1779077729.1484556,
|
||||
"seen": 1787114579.0303009,
|
||||
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
|
||||
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
|
||||
},
|
||||
"docs/5_topbar.png": {
|
||||
"mtime": 1779076905.6012611,
|
||||
"seen": 1787114579.0303018,
|
||||
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
|
||||
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
|
||||
},
|
||||
"docs/6_ws_library.png": {
|
||||
"mtime": 1781057870.2308166,
|
||||
"seen": 1787114579.0303028,
|
||||
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
|
||||
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
|
||||
},
|
||||
"docs/7_library_Infomation.png": {
|
||||
"mtime": 1779076410.2629254,
|
||||
"seen": 1787114579.0303032,
|
||||
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
|
||||
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
|
||||
},
|
||||
"docs/8_Customize.png": {
|
||||
"mtime": 1779077388.8494852,
|
||||
"seen": 1787114579.030304,
|
||||
"ast_hash": "c9f979d40753ce41c015b2d594007658",
|
||||
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
|
||||
},
|
||||
"docs/9_GenUI_LoraStack.png": {
|
||||
"mtime": 1781057821.3736062,
|
||||
"seen": 1787114579.030305,
|
||||
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
|
||||
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
|
||||
},
|
||||
"docs/thumb.png": {
|
||||
"mtime": 1778457744.7955222,
|
||||
"seen": 1787114579.0303059,
|
||||
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
|
||||
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
|
||||
},
|
||||
"static/favicon.svg": {
|
||||
"mtime": 1773877378.0368707,
|
||||
"seen": 1787114579.0303063,
|
||||
"ast_hash": "0cd547c25c922d632890be287d6084ff",
|
||||
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
|
||||
"mtime": 1779256606.562932,
|
||||
"seen": 1787114579.0303073,
|
||||
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
|
||||
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
|
||||
"mtime": 1779257129.3453796,
|
||||
"seen": 1787114579.0303097,
|
||||
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
|
||||
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
|
||||
"mtime": 1779257864.2375357,
|
||||
"seen": 1787114579.0303104,
|
||||
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
|
||||
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.png": {
|
||||
"mtime": 1779255324.1356418,
|
||||
"seen": 1787114579.0303113,
|
||||
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
|
||||
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.png": {
|
||||
"mtime": 1784801838.7777002,
|
||||
"seen": 1787114579.0303123,
|
||||
"ast_hash": "79321edb85aed7c0a8c777accc005486",
|
||||
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.png": {
|
||||
"mtime": 1784801946.027666,
|
||||
"seen": 1787114579.030313,
|
||||
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
|
||||
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.png": {
|
||||
"mtime": 1779253098.1897035,
|
||||
"seen": 1787114579.0303137,
|
||||
"ast_hash": "9582bb698439fff1a748315145538777",
|
||||
"semantic_hash": "9582bb698439fff1a748315145538777"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
|
||||
"mtime": 1779256158.4621174,
|
||||
"seen": 1787114579.0303144,
|
||||
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
|
||||
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
|
||||
"mtime": 1779255768.6191878,
|
||||
"seen": 1787114579.0303156,
|
||||
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
|
||||
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.png": {
|
||||
"mtime": 1779255037.8606594,
|
||||
"seen": 1787114579.0303164,
|
||||
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
|
||||
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.png": {
|
||||
"mtime": 1779254360.9671092,
|
||||
"seen": 1787114579.030317,
|
||||
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
|
||||
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.png": {
|
||||
"mtime": 1779253668.0007572,
|
||||
"seen": 1787114579.0303178,
|
||||
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
|
||||
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.png": {
|
||||
"mtime": 1779254714.0099926,
|
||||
"seen": 1787114579.0303185,
|
||||
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
|
||||
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.png": {
|
||||
"mtime": 1779253180.3890488,
|
||||
"seen": 1787114579.0303195,
|
||||
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
|
||||
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.png": {
|
||||
"mtime": 1779253367.7925913,
|
||||
"seen": 1787114579.0303202,
|
||||
"ast_hash": "51cae742b39316023cf5332f1d794854",
|
||||
"semantic_hash": "51cae742b39316023cf5332f1d794854"
|
||||
},
|
||||
"static/js/image-edit/BgRemove.js": {
|
||||
"mtime": 1787126632.8601232,
|
||||
"seen": 1787194338.8008947,
|
||||
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
|
||||
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
|
||||
},
|
||||
"static/js/image-edit/BlurTool.js": {
|
||||
"mtime": 1787126263.0542421,
|
||||
"seen": 1787194338.8008955,
|
||||
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
|
||||
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
|
||||
},
|
||||
"static/js/image-edit/FileExport.js": {
|
||||
"mtime": 1787192008.741717,
|
||||
"seen": 1787194338.8008974,
|
||||
"ast_hash": "cf645b775e537ad276c9e070c7849349",
|
||||
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
|
||||
},
|
||||
"static/js/image-edit/GmicIntegration.js": {
|
||||
"mtime": 1787125777.3889306,
|
||||
"seen": 1787194338.8008983,
|
||||
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
|
||||
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
|
||||
},
|
||||
"static/js/image-edit/InpaintI2IActions.js": {
|
||||
"mtime": 1787127273.4351766,
|
||||
"seen": 1787194338.800899,
|
||||
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
|
||||
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
|
||||
},
|
||||
"static/js/image-edit/Sam3Segmentation.js": {
|
||||
"mtime": 1787126893.774897,
|
||||
"seen": 1787194338.800904,
|
||||
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
|
||||
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
|
||||
},
|
||||
".github/workflows/publish.yml": {
|
||||
"mtime": 1778460176.5699222,
|
||||
"seen": 1787130571.383575,
|
||||
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
|
||||
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
|
||||
},
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1786327833.871171,
|
||||
"seen": 1787130571.3835762,
|
||||
"ast_hash": "c36546d2d007859555904d89e55a8ed7",
|
||||
"semantic_hash": "c36546d2d007859555904d89e55a8ed7"
|
||||
},
|
||||
"DEVLOG.md": {
|
||||
"mtime": 1787193273.171442,
|
||||
"seen": 1787194338.8009443,
|
||||
"ast_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4",
|
||||
"semantic_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1787193228.4769182,
|
||||
"seen": 1787194338.8009455,
|
||||
"ast_hash": "858c9d43205808ad6a111defeb66859c",
|
||||
"semantic_hash": "858c9d43205808ad6a111defeb66859c"
|
||||
},
|
||||
"requirements.txt": {
|
||||
"mtime": 1787036261.1817627,
|
||||
"seen": 1787130571.3835788,
|
||||
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
|
||||
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
|
||||
},
|
||||
"static/js/models/badges.js": {
|
||||
"mtime": 1787143926.3653927,
|
||||
"seen": 1787194338.800913,
|
||||
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
|
||||
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
|
||||
},
|
||||
"static/js/models/detail-panel.js": {
|
||||
"mtime": 1787144898.6748104,
|
||||
"seen": 1787194338.8009143,
|
||||
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
|
||||
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
|
||||
},
|
||||
"static/js/models/filters.js": {
|
||||
"mtime": 1787144201.7602522,
|
||||
"seen": 1787194338.8009152,
|
||||
"ast_hash": "afd754f68d01f64c51995929b3841e56",
|
||||
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
|
||||
},
|
||||
"static/js/models/grid-view.js": {
|
||||
"mtime": 1787144745.5522888,
|
||||
"seen": 1787194338.8009164,
|
||||
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
|
||||
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
|
||||
},
|
||||
"static/js/models/helpers.js": {
|
||||
"mtime": 1787144193.741004,
|
||||
"seen": 1787194338.8009179,
|
||||
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
|
||||
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
|
||||
},
|
||||
"static/js/models/selection-bulk.js": {
|
||||
"mtime": 1787144787.0092065,
|
||||
"seen": 1787194338.8009186,
|
||||
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
|
||||
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
|
||||
},
|
||||
"static/js/models/state.js": {
|
||||
"mtime": 1787143674.705312,
|
||||
"seen": 1787194338.8009193,
|
||||
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
|
||||
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneBridge.js": {
|
||||
"mtime": 1787187872.7481277,
|
||||
"seen": 1787194338.8009012,
|
||||
"ast_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a",
|
||||
"semantic_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Runs before ComfyUI imports any custom node — the only place early enough
|
||||
to populate os.environ for code that reads env vars at import/request time.
|
||||
|
||||
Loads a .env file (if present) from this plugin's root directory so the
|
||||
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
|
||||
settings.json or exposing it to the frontend.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = PLUGIN_DIR / ".env"
|
||||
|
||||
if ENV_FILE.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(ENV_FILE)
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
|
||||
@@ -69,6 +69,8 @@ NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
AI_SKILLS_DIR = DATA_DIR / "ai_skills"
|
||||
LAB_PLAN_DIR = DATA_DIR / "lab_plan"
|
||||
|
||||
# Tagger
|
||||
TAGGER_DB_FILE = DATA_DIR / "tagger.db"
|
||||
|
||||
+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)
|
||||
|
||||
+101
-18
@@ -2,11 +2,9 @@
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
@@ -43,6 +41,16 @@ def _init_allowed_root() -> None:
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir: %s", e)
|
||||
|
||||
# ImagePrompt/Style Catalogギャラリー用に実outputフォルダを常に別途保持する
|
||||
# (Settingsで_allowed_rootが別フォルダに変更されていても影響しないようにするため)
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir and Path(output_dir).is_dir():
|
||||
_service.set_comfy_output_root(output_dir)
|
||||
except Exception as e:
|
||||
logger.warning("GalleryRoutes: failed to get ComfyUI output dir for image-prompt root: %s", e)
|
||||
|
||||
|
||||
_init_allowed_root()
|
||||
|
||||
@@ -55,7 +63,12 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
|
||||
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
|
||||
app.router.add_get("/wfm/gallery/style-catalog/root", get_style_catalog_root)
|
||||
app.router.add_post("/wfm/gallery/image/save", save_image_to_gallery)
|
||||
app.router.add_post("/wfm/gallery/image/save-to-folder", save_image_to_folder_route)
|
||||
app.router.add_post("/wfm/gallery/output-image/delete", delete_output_image)
|
||||
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
|
||||
app.router.add_get("/wfm/gallery/groups", list_groups)
|
||||
app.router.add_post("/wfm/gallery/groups", create_group)
|
||||
@@ -104,6 +117,7 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
recursive = request.rel_url.query.get("recursive", "false") == "true"
|
||||
|
||||
if not folder:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
@@ -117,6 +131,7 @@ async def list_images(request: web.Request) -> web.Response:
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
recursive=recursive,
|
||||
)
|
||||
# 検索なしのフォルダロード時にバックグラウンドインデックスを起動
|
||||
if not search:
|
||||
@@ -248,6 +263,36 @@ async def save_image_meta(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_prompt_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/image-prompt/root — ImagePromptギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_image_prompt_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def save_image_prompt(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/image-prompt — 画像の.txtサイドカーにプロンプトを保存"""
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
prompt = body.get("prompt", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
ok = _service.save_image_prompt(img_path, prompt)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_style_catalog_root(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/style-catalog/root — Style Catalogギャラリーのルートフォルダを返す"""
|
||||
root = _service.get_style_catalog_root()
|
||||
if root is None:
|
||||
return web.json_response({"error": "ComfyUI output directory not resolved"}, status=500)
|
||||
return web.json_response({"root": root})
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -261,7 +306,8 @@ async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
"""Image Edit Tab からの PNG をギャラリーのルートフォルダに保存する。"""
|
||||
"""Image Edit Tab / AI Chat からの画像(PNG等)やSVGをギャラリーのルートフォルダに保存する。
|
||||
imageData は data URL (data:<mime>;base64,<data>) を想定。ヘッダがなければPNG扱い(旧互換)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "").strip()
|
||||
@@ -273,27 +319,64 @@ async def save_image_to_gallery(request: web.Request) -> web.Response:
|
||||
if _service._allowed_root is None:
|
||||
return web.json_response({"error": "Gallery root not configured. Open Gallery tab and set output folder first."}, status=500)
|
||||
|
||||
# ファイル名サニタイズ(OSで使えない文字を除去)
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(".png"):
|
||||
safe += ".png"
|
||||
result = _service.save_image_to_folder(str(_service._allowed_root), filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
|
||||
# base64 data URL をデコード
|
||||
raw = image_data
|
||||
if "," in raw:
|
||||
raw = raw.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(raw)
|
||||
|
||||
save_path = _service._allowed_root / safe
|
||||
save_path.write_bytes(image_bytes)
|
||||
|
||||
logger.info("save_image_to_gallery: saved %s (%d bytes)", save_path, len(image_bytes))
|
||||
return web.json_response({"ok": True, "path": str(save_path)})
|
||||
logger.info("save_image_to_gallery: saved %s", result["path"])
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_gallery error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def save_image_to_folder_route(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/save-to-folder — 任意フォルダへ画像を保存(同名なら上書き)。
|
||||
Style Catalogのカタログ作成で、スタイル名をファイル名として使う際に利用する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
folder = body.get("folder", "").strip()
|
||||
filename = body.get("filename", "").strip()
|
||||
image_data = body.get("imageData", "")
|
||||
|
||||
if not folder or not filename or not image_data:
|
||||
return web.json_response({"error": "folder, filename and imageData required"}, status=400)
|
||||
|
||||
result = _service.save_image_to_folder(folder, filename, image_data)
|
||||
if not result["ok"]:
|
||||
return web.json_response({"error": result["error"]}, status=400)
|
||||
return web.json_response({"ok": True, "path": result["path"]})
|
||||
except Exception as e:
|
||||
logger.error("save_image_to_folder_route error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_output_image(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/output-image/delete — ComfyUI標準の画像参照(filename/subfolder/type)から
|
||||
実ファイルを削除する。Style Catalogのカタログ作成で「Outputフォルダに画像を残さない」
|
||||
オプションが有効な時、カタログへコピー保存した直後の元画像を消すために使う。
|
||||
type="output" 以外は拒否する(temp/inputの誤削除を防止)。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = (body.get("filename") or "").strip()
|
||||
subfolder = body.get("subfolder") or ""
|
||||
img_type = body.get("type") or ""
|
||||
if not filename or img_type != "output":
|
||||
return web.json_response({"error": "filename required and type must be 'output'"}, status=400)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
target = output_dir / subfolder / filename
|
||||
|
||||
result = await asyncio.to_thread(_service.delete_images, [str(target)])
|
||||
if result["deleted"]:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"error": "; ".join(result["errors"]) or "File not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("delete_output_image error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Lab tab plan file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import LAB_PLAN_DIR
|
||||
from ..services.lab_plan_service import LabPlanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = LabPlanService(LAB_PLAN_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/lab/plans", handle_list)
|
||||
app.router.add_get("/api/wfm/lab/plans/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/lab/plans/save", handle_save)
|
||||
app.router.add_post("/api/wfm/lab/plans/delete", handle_delete)
|
||||
app.router.add_post("/api/wfm/lab/index-image/save-to-output", handle_save_index_to_output)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_plans)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing lab plans: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
data = await asyncio.to_thread(_service.get_plan, filename)
|
||||
return web.json_response({"data": data})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error getting lab plan content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
data = body.get("data", {})
|
||||
index_image_base64 = body.get("index_image_base64")
|
||||
result = await asyncio.to_thread(_service.save_plan, filename, data, index_image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_plan, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error deleting lab plan: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_index_to_output(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
image_base64 = body.get("image_base64", "")
|
||||
if not image_base64:
|
||||
return web.json_response({"error": "image_base64 required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.save_index_image_to_output, image_base64)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving lab index image to output: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -38,6 +38,9 @@ def setup_routes(app: web.Application):
|
||||
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)
|
||||
app.router.add_post("/api/wfm/styles", handle_create_style)
|
||||
app.router.add_put("/api/wfm/styles/{name}", handle_update_style)
|
||||
app.router.add_delete("/api/wfm/styles/{name}", handle_delete_style)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -245,18 +248,28 @@ async def handle_import(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory."""
|
||||
def _style_files() -> list:
|
||||
style_dir = DATA_DIR / "style"
|
||||
styles = []
|
||||
if not style_dir.is_dir():
|
||||
return styles
|
||||
for json_file in sorted(style_dir.glob("*.json")):
|
||||
return []
|
||||
return sorted(style_dir.glob("*.json"))
|
||||
|
||||
|
||||
def _load_styles() -> list:
|
||||
"""Load all style JSON files from DATA_DIR/style/ directory.
|
||||
各エントリに定義元ファイル名を "file" として付与する(Promptタブの一覧表示・
|
||||
「このファイルへ追加」機能で使用)。"""
|
||||
styles = []
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
styles.extend(data)
|
||||
for entry in data:
|
||||
if isinstance(entry, dict):
|
||||
entry = dict(entry)
|
||||
entry["file"] = json_file.name
|
||||
styles.append(entry)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
return styles
|
||||
@@ -270,3 +283,139 @@ async def handle_get_styles(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error loading styles: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# Promptタブ「Style」サブタブでの新規作成分の保存先。既存スタイルの編集は
|
||||
# 元々そのスタイルが記載されているファイルをそのまま書き換える(他ファイルへは移動しない)。
|
||||
_CUSTOM_STYLE_FILENAME = "custom.json"
|
||||
|
||||
|
||||
def _find_style(name: str):
|
||||
"""名前が一致する最初のスタイルを探し、(ファイルパス, そのファイルの全データ, インデックス) を返す。
|
||||
見つからなければ None。"""
|
||||
for json_file in _style_files():
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load style file %s: %s", json_file.name, e)
|
||||
continue
|
||||
if not isinstance(data, list):
|
||||
continue
|
||||
for i, entry in enumerate(data):
|
||||
if isinstance(entry, dict) and entry.get("name") == name:
|
||||
return json_file, data, i
|
||||
return None
|
||||
|
||||
|
||||
def _write_style_file(path: Path, data: list) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
async def handle_create_style(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/styles - 新規スタイルを作成する。
|
||||
body.file を指定すると既存のそのファイルへ追記する(Promptタブの「このファイルへ追加」用、
|
||||
ファイル名はstyleディレクトリに実在するもののみ許可しパストラバーサルを防ぐ)。
|
||||
未指定ならデフォルトのcustom.jsonへ追記する。"""
|
||||
try:
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
target_file = (body.get("file") or "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _create():
|
||||
if _find_style(name) is not None:
|
||||
return "duplicate"
|
||||
style_dir = DATA_DIR / "style"
|
||||
style_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target_file:
|
||||
path = next((f for f in _style_files() if f.name == target_file), None)
|
||||
if path is None:
|
||||
return "file_not_found"
|
||||
else:
|
||||
path = style_dir / _CUSTOM_STYLE_FILENAME
|
||||
|
||||
data = []
|
||||
if path.is_file():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, list):
|
||||
data = loaded
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load %s, recreating: %s", path.name, e)
|
||||
data.append({"name": name, "prompt": prompt, "negative_prompt": negative_prompt})
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_create)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{name}" already exists'}, status=400)
|
||||
if result == "file_not_found":
|
||||
return web.json_response({"error": f'File "{target_file}" not found'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error creating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_update_style(request: web.Request) -> web.Response:
|
||||
"""PUT /api/wfm/styles/{name} - 既存スタイルを更新する(記載元のファイルをそのまま書き換え、リネームも可)。"""
|
||||
try:
|
||||
original_name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
new_name = (body.get("name") or "").strip()
|
||||
prompt = body.get("prompt", "") or ""
|
||||
negative_prompt = body.get("negative_prompt", "") or ""
|
||||
if not new_name:
|
||||
return web.json_response({"error": "name is required"}, status=400)
|
||||
|
||||
def _update():
|
||||
found = _find_style(original_name)
|
||||
if found is None:
|
||||
return "not_found"
|
||||
path, data, idx = found
|
||||
if new_name != original_name and _find_style(new_name) is not None:
|
||||
return "duplicate"
|
||||
data[idx] = {"name": new_name, "prompt": prompt, "negative_prompt": negative_prompt}
|
||||
_write_style_file(path, data)
|
||||
return "ok"
|
||||
|
||||
result = await asyncio.to_thread(_update)
|
||||
if result == "not_found":
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
if result == "duplicate":
|
||||
return web.json_response({"error": f'Style "{new_name}" already exists'}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error updating style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_style(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/wfm/styles/{name} - スタイルを削除する(記載元のファイルから該当エントリのみ除去)。"""
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
|
||||
def _delete():
|
||||
found = _find_style(name)
|
||||
if found is None:
|
||||
return False
|
||||
path, data, idx = found
|
||||
del data[idx]
|
||||
_write_style_file(path, data)
|
||||
return True
|
||||
|
||||
ok = await asyncio.to_thread(_delete)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Style not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting style: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""AI skill (.md system prompt) file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import AI_SKILLS_DIR
|
||||
from ..services.skill_service import SkillService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SkillService(AI_SKILLS_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/skills", handle_list)
|
||||
app.router.add_get("/api/wfm/skills/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/skills/save", handle_save)
|
||||
app.router.add_post("/api/wfm/skills/delete", handle_delete)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_skills)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing skills: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
content = await asyncio.to_thread(_service.get_content, filename)
|
||||
return web.json_response({"content": content})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("Error getting skill content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
content = body.get("content", "")
|
||||
result = await asyncio.to_thread(_service.save_file, filename, content)
|
||||
return web.json_response({"status": "ok", "file": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_file, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unsloth API proxy routes.
|
||||
|
||||
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
|
||||
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
|
||||
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
|
||||
never sent to the frontend — the frontend calls this proxy with the target
|
||||
path/method/payload, and the server attaches the Authorization header before
|
||||
relaying to Unsloth's OpenAI-compatible API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
|
||||
# baseUrl is client-supplied (so a custom Unsloth port works), but the
|
||||
# Authorization header carries a real secret — restrict the host to loopback
|
||||
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
|
||||
# server (SSRF). Port is unrestricted.
|
||||
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _get_api_key():
|
||||
"""Return the Unsloth API key from the environment (.env), or None."""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def _is_allowed_base_url(base_url):
|
||||
"""Only relay (and attach the API key) to a loopback host."""
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register Unsloth API routes."""
|
||||
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
body = await request.json()
|
||||
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
|
||||
path = body.get("path") or "/v1/models"
|
||||
method = (body.get("method") or "GET").upper()
|
||||
payload = body.get("payload")
|
||||
|
||||
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
|
||||
return web.json_response({"message": "Unsupported proxy target"}, status=400)
|
||||
|
||||
if not _is_allowed_base_url(base_url):
|
||||
return web.json_response({
|
||||
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
|
||||
}, status=400)
|
||||
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return web.json_response({
|
||||
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI.",
|
||||
}, status=401)
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
return web.json_response(data)
|
||||
|
||||
except HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
|
||||
except URLError as e:
|
||||
logger.warning("Unsloth proxy connection error: %s", e)
|
||||
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
|
||||
except Exception as e:
|
||||
logger.error("Unsloth proxy error: %s", e)
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
+107
-81
@@ -3,6 +3,7 @@ Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,14 +25,24 @@ class GalleryMetadataStore:
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
|
||||
self._data(特に "images" 辞書)は、バックグラウンドのフォルダインデックススレッド
|
||||
(gallery_service.pyの_bg_index_folder)と、リクエストハンドラを処理するメインスレッドの
|
||||
双方から並行に読み書きされる。ロックなしで辞書を反復(.items()/.values()/for key in ...や
|
||||
json.dumpによるシリアライズ)している最中に別スレッドがキーを追加/削除すると
|
||||
"dictionary changed size during iteration" が発生するため、_data へアクセスする
|
||||
全メソッドを self._lock で保護する(re-entrant: ensure_group()がcreate_group()を
|
||||
内部呼び出しするため、通常のLockではなくRLockを使う)。
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._lock = threading.RLock()
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
# コンストラクタからのみ呼ばれ、この時点ではまだ他スレッドと共有されていないためロック不要
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
@@ -44,6 +55,7 @@ class GalleryMetadataStore:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
|
||||
def _save_to_disk(self):
|
||||
# 呼び出し元(save/create_group等)が既にself._lockを保持している前提の内部メソッド
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
@@ -58,113 +70,126 @@ class GalleryMetadataStore:
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
with self._lock:
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
with self._lock:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
with self._lock:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def remove_stale_paths_from_group(self, group_name: str, stale_paths: list) -> int:
|
||||
"""存在しないパスをグループから一括削除する(1回の保存で完結)"""
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
with self._lock:
|
||||
stale_set = {self._normalize_path(p) for p in stale_paths}
|
||||
count = 0
|
||||
for key, meta in self._data["images"].items():
|
||||
if key in stale_set and group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
count += 1
|
||||
if count:
|
||||
self._save_to_disk()
|
||||
return count
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
with self._lock:
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
def clear_group(self, group_name: str) -> bool:
|
||||
"""グループ内の全画像を除外する(グループ自体は残す)"""
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
with self._lock:
|
||||
for meta in self._data["images"].values():
|
||||
if group_name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != group_name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def ensure_group(self, name: str) -> bool:
|
||||
"""グループが存在しない場合のみ作成する"""
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
with self._lock:
|
||||
if any(g["name"] == name for g in self._data.get("groups", [])):
|
||||
return True
|
||||
return self.create_group(name)
|
||||
|
||||
def delete(self, image_path: str) -> bool:
|
||||
"""画像のメタデータエントリを削除する"""
|
||||
key = self._normalize_path(image_path)
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if key in self._data["images"]:
|
||||
del self._data["images"][key]
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def rename_path(self, old_path: str, new_path: str) -> bool:
|
||||
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
|
||||
old_key = self._normalize_path(old_path)
|
||||
new_key = self._normalize_path(new_path)
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
with self._lock:
|
||||
if old_key in self._data["images"]:
|
||||
self._data["images"][new_key] = self._data["images"].pop(old_key)
|
||||
return self._save_to_disk()
|
||||
return True
|
||||
|
||||
def cleanup_stale_images(self, folder_path: str, existing_paths: set) -> int:
|
||||
"""folder_path 配下の存在しないファイルのメタデータを削除する。
|
||||
@@ -173,14 +198,15 @@ class GalleryMetadataStore:
|
||||
folder_key = self._normalize_path(folder_path)
|
||||
if not folder_key.endswith("/"):
|
||||
folder_key += "/"
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
with self._lock:
|
||||
stale = [
|
||||
key for key in self._data["images"]
|
||||
if key.startswith(folder_key) and key not in existing_paths
|
||||
]
|
||||
if not stale:
|
||||
return 0
|
||||
for key in stale:
|
||||
del self._data["images"][key]
|
||||
self._save_to_disk()
|
||||
logger.info("Cleaned up %d stale metadata entries from %s", len(stale), folder_path)
|
||||
return len(stale)
|
||||
|
||||
+270
-12
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -16,7 +17,38 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}
|
||||
|
||||
# data URL の MIME タイプ -> 拡張子(save_image_to_gallery / save_image_to_folder共用)
|
||||
_SAVE_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _decode_image_data_url(image_data: str) -> tuple[bytes, str]:
|
||||
"""data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。
|
||||
ヘッダが無ければPNG扱い(旧互換)。"""
|
||||
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")
|
||||
return base64.b64decode(raw), ext
|
||||
|
||||
|
||||
def _sanitize_save_filename(filename: str, ext: str) -> str:
|
||||
"""OSで使えない文字を除去し、拡張子が無ければ付与する"""
|
||||
safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", filename)
|
||||
if not safe.lower().endswith(ext):
|
||||
safe += ext
|
||||
return safe
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
@@ -30,6 +62,54 @@ _JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 60.0
|
||||
|
||||
# ImagePromptギャラリー: 画像と同名の.txtサイドカーに保存されたプロンプトテキストの最大読み込みサイズ
|
||||
_SIDECAR_PROMPT_MAX = 20_000
|
||||
|
||||
# ImagePromptギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
|
||||
IMAGE_PROMPT_FOLDER_NAME = "ws_image_prompt"
|
||||
|
||||
# Style Catalogギャラリーの管理フォルダ名(ComfyUI実outputフォルダ直下)
|
||||
STYLE_CATALOG_FOLDER_NAME = "ws_style_catalog"
|
||||
|
||||
# ponyxlWildcardsVault形式(.yaml + thumbnails/)を検出するための、画像の祖先フォルダ探索上限
|
||||
_VAULT_ROOT_SEARCH_DEPTH = 8
|
||||
_VAULT_THUMB_DIR_NAMES = ("thumbnails", "thumbnails_option2")
|
||||
|
||||
|
||||
def _clean_vault_tags(text: str) -> str:
|
||||
"""タグ文字列の前後の空白・カンマを整理する(tools/import_style_prompt_seed.py と同じ処理)"""
|
||||
text = text.strip()
|
||||
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
|
||||
text = re.sub(r"\s*,\s*", ", ", text)
|
||||
return text
|
||||
|
||||
|
||||
def _parse_vault_yaml_leaves(yaml_path: Path) -> dict[str, str]:
|
||||
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
|
||||
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
|
||||
戻り値: {leaf_name(小文字): タグ文字列}
|
||||
"""
|
||||
leaves: dict[str, str] = {}
|
||||
try:
|
||||
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
|
||||
except OSError:
|
||||
return leaves
|
||||
for i, line in enumerate(lines):
|
||||
trimmed = line.strip()
|
||||
if not trimmed or trimmed.startswith("#") or not trimmed.endswith(":"):
|
||||
continue
|
||||
if i + 1 >= len(lines):
|
||||
continue
|
||||
next_trimmed = lines[i + 1].strip()
|
||||
if not next_trimmed.startswith("-"):
|
||||
continue
|
||||
key = trimmed[:-1].strip()
|
||||
tags = next_trimmed[1:].strip()
|
||||
if not tags or key.lower() == "skip":
|
||||
continue
|
||||
leaves[key.lower()] = _clean_vault_tags(tags)
|
||||
return leaves
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
"""フォルダ単位の画像スキャン結果キャッシュ。
|
||||
@@ -73,15 +153,26 @@ class GalleryService:
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
# ComfyUI実outputフォルダの不変ルート。Settingsでユーザーが_allowed_rootを
|
||||
# 別フォルダに変更しても、ImagePrompt/Style Catalogギャラリー(常に実output配下)への
|
||||
# アクセスが塞がれないようにするため_allowed_rootとは別管理にする
|
||||
self._comfy_output_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
self._bg_cancel = threading.Event()
|
||||
self._bg_cancel.set() # 初期状態: キャンセル済み
|
||||
# ponyxlWildcardsVault形式フォールバック用キャッシュ: category_root文字列 -> (yamlシグネチャ, {leaf: tags})
|
||||
self._vault_leaf_cache: dict[str, tuple[tuple, dict]] = {}
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
def set_comfy_output_root(self, root_path: str) -> None:
|
||||
"""ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._comfy_output_root = p
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バックグラウンドインデックス
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -147,14 +238,23 @@ class GalleryService:
|
||||
logger.debug("BG index error: %s", e)
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
return False
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)。
|
||||
Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの
|
||||
_comfy_output_root のいずれか配下であれば許可する。"""
|
||||
resolved = path.resolve()
|
||||
if self._allowed_root is not None:
|
||||
try:
|
||||
resolved.relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
if self._comfy_output_root is not None:
|
||||
try:
|
||||
resolved.relative_to(self._comfy_output_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
@@ -183,11 +283,13 @@ class GalleryService:
|
||||
except PermissionError:
|
||||
pass
|
||||
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
|
||||
total_count = image_count + sum(c["image_count_total"] for c in children_nodes)
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": rel if rel != "." else "",
|
||||
"abs_path": str(path).replace("\\", "/"),
|
||||
"image_count": image_count,
|
||||
"image_count_total": total_count,
|
||||
"children": children_nodes,
|
||||
}
|
||||
|
||||
@@ -230,6 +332,33 @@ class GalleryService:
|
||||
self._folder_cache.set(folder, entries)
|
||||
return entries
|
||||
|
||||
def _scan_folder_recursive(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。
|
||||
ImagePrompt/Style Catalogギャラリーのように深い階層に画像が置かれるケース用。
|
||||
キャッシュはしない(フォルダ数が少なく、都度スキャンしても軽い想定)。
|
||||
"""
|
||||
entries = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for name in files:
|
||||
if Path(name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
full = Path(root) / name
|
||||
try:
|
||||
st = full.stat()
|
||||
entries.append((
|
||||
name,
|
||||
str(full.resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
self,
|
||||
folder_path: str,
|
||||
@@ -238,8 +367,9 @@ class GalleryService:
|
||||
favorite_only: bool = False,
|
||||
tag_filter: str = "",
|
||||
group_filter: str = "",
|
||||
recursive: bool = False,
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
|
||||
"""指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
@@ -253,7 +383,7 @@ class GalleryService:
|
||||
group_member_set = self.metadata_store.get_group_member_set(group_filter)
|
||||
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
raw_entries = self._scan_folder_recursive(folder) if recursive else self._scan_folder(folder)
|
||||
|
||||
# 孤立メタデータを自動クリーンアップ(移動・削除されたファイルの残骸を除去)
|
||||
existing_paths = {abs_path for _, abs_path, _, _ in raw_entries}
|
||||
@@ -289,14 +419,16 @@ class GalleryService:
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト)
|
||||
# 検索フィルタ(ファイル名・メモ・タグ・キャッシュ済みプロンプト・.txtサイドカー)
|
||||
if search:
|
||||
s = search.lower()
|
||||
sidecar_prompt = self._read_sidecar_prompt(Path(abs_path)) or ""
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
or s in meta.get("prompt_cache", "").lower()
|
||||
or s in sidecar_prompt.lower()
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -314,6 +446,126 @@ class GalleryService:
|
||||
|
||||
return results
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# ImagePromptギャラリー: .txtサイドカーによるプロンプト保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_image_prompt_root(self) -> str | None:
|
||||
"""ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を
|
||||
返す。無ければ作成する。"""
|
||||
if self._comfy_output_root is None:
|
||||
return None
|
||||
root = self._comfy_output_root / IMAGE_PROMPT_FOLDER_NAME
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return str(root).replace("\\", "/")
|
||||
|
||||
def get_style_catalog_root(self) -> str | None:
|
||||
"""Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を
|
||||
返す。無ければ作成する。"""
|
||||
if self._comfy_output_root is None:
|
||||
return None
|
||||
root = self._comfy_output_root / STYLE_CATALOG_FOLDER_NAME
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return str(root).replace("\\", "/")
|
||||
|
||||
def save_image_to_folder(self, folder_path: str, filename: str, image_data: str) -> dict:
|
||||
"""任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。
|
||||
Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return {"ok": False, "error": "Destination folder not found"}
|
||||
if not self._check_path_allowed(folder):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
try:
|
||||
image_bytes, ext = _decode_image_data_url(image_data)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Invalid imageData: {e}"}
|
||||
safe = _sanitize_save_filename(filename, ext)
|
||||
if not safe:
|
||||
return {"ok": False, "error": "Invalid filename"}
|
||||
save_path = folder / safe
|
||||
try:
|
||||
save_path.write_bytes(image_bytes)
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"ok": True, "path": str(save_path).replace("\\", "/")}
|
||||
|
||||
def _sidecar_path(self, image_path: Path) -> Path:
|
||||
return image_path.with_suffix(".txt")
|
||||
|
||||
def _read_sidecar_prompt(self, image_path: Path) -> str | None:
|
||||
"""画像のプロンプトテキストを返す。
|
||||
1. 画像と同名の .txt サイドカーがあればそれを使う
|
||||
2. 無ければ ponyxlWildcardsVault 形式 (*.yaml + thumbnails/) をフォールバックとして
|
||||
その場で解決する(インポート未実施のフォルダをそのまま置いた場合に対応するため)
|
||||
"""
|
||||
sidecar = self._sidecar_path(image_path)
|
||||
try:
|
||||
if sidecar.is_file() and sidecar.stat().st_size <= _SIDECAR_PROMPT_MAX:
|
||||
text = sidecar.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if text:
|
||||
return text
|
||||
except OSError:
|
||||
pass
|
||||
return self._read_vault_prompt(image_path)
|
||||
|
||||
def _find_vault_category_root(self, image_path: Path) -> Path | None:
|
||||
"""image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ
|
||||
(=ponyxlWildcardsVaultのカテゴリルート) を探す"""
|
||||
cur = image_path.parent
|
||||
for _ in range(_VAULT_ROOT_SEARCH_DEPTH):
|
||||
if any((cur / name).is_dir() for name in _VAULT_THUMB_DIR_NAMES):
|
||||
return cur
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
return None
|
||||
|
||||
def _load_vault_leaves(self, category_root: Path) -> dict[str, str]:
|
||||
yaml_files = sorted(category_root.glob("*.yaml")) + sorted(category_root.glob("*.yml"))
|
||||
if not yaml_files:
|
||||
return {}
|
||||
try:
|
||||
signature = tuple(sorted((str(f), f.stat().st_mtime) for f in yaml_files))
|
||||
except OSError:
|
||||
signature = ()
|
||||
cache_key = str(category_root)
|
||||
cached = self._vault_leaf_cache.get(cache_key)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return cached[1]
|
||||
leaves: dict[str, str] = {}
|
||||
for yf in yaml_files:
|
||||
leaves.update(_parse_vault_yaml_leaves(yf))
|
||||
self._vault_leaf_cache[cache_key] = (signature, leaves)
|
||||
return leaves
|
||||
|
||||
def _read_vault_prompt(self, image_path: Path) -> str | None:
|
||||
"""ponyxlWildcardsVault形式のフォールバック解決。
|
||||
thumbnails_option2 の "{leaf}.preview3.ext" のような二重拡張子にも対応するため、
|
||||
ファイル名の最初の "." より前をリーフ名候補として使う。"""
|
||||
category_root = self._find_vault_category_root(image_path)
|
||||
if category_root is None:
|
||||
return None
|
||||
leaves = self._load_vault_leaves(category_root)
|
||||
if not leaves:
|
||||
return None
|
||||
leaf_candidate = image_path.name.split(".")[0].lower()
|
||||
return leaves.get(leaf_candidate)
|
||||
|
||||
def save_image_prompt(self, image_path: str, text: str) -> bool:
|
||||
"""画像と同名の.txtサイドカーにプロンプトテキストを保存する"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return False
|
||||
if not self._check_path_allowed(p):
|
||||
return False
|
||||
try:
|
||||
self._sidecar_path(p).write_text((text or "").strip(), encoding="utf-8")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("save_image_prompt: failed for %s: %s", p, e)
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -355,6 +607,7 @@ class GalleryService:
|
||||
"tags": saved.get("tags", []),
|
||||
"memo": saved.get("memo", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
"image_prompt": self._read_sidecar_prompt(path),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
@@ -684,6 +937,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:
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Lab tab plan file management service (JSON plan + optional PNG index thumbnail)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabPlanService:
|
||||
def __init__(self, plan_dir: Path):
|
||||
self.plan_dir = plan_dir
|
||||
self.plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _safe_path(self, filename: str, ext: str = ".json") -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
name = filename.strip()
|
||||
if not name.endswith(ext):
|
||||
name = name + ext
|
||||
if "/" in name or "\\" in name or name in (".", "..") or "\x00" in name:
|
||||
return None
|
||||
resolved = (self.plan_dir / name).resolve()
|
||||
plan_dir_resolved = self.plan_dir.resolve()
|
||||
if resolved != plan_dir_resolved and not str(resolved).startswith(str(plan_dir_resolved) + "\\") and not str(resolved).startswith(str(plan_dir_resolved) + "/"):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
def _now_iso(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_plans(self) -> list[dict]:
|
||||
if not self.plan_dir.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for path in sorted(self.plan_dir.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning("Skipping unreadable lab plan %s: %s", path.name, e)
|
||||
continue
|
||||
|
||||
stem = path.stem
|
||||
thumbnail = None
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumbnail = f"/wfm_data/lab_plan/{stem}.png"
|
||||
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"name": data.get("name", stem),
|
||||
"note": data.get("note", ""),
|
||||
"batch_count": data.get("batch_count", 1),
|
||||
"updated_at": data.get("updated_at", ""),
|
||||
"thumbnail": thumbnail,
|
||||
})
|
||||
result.sort(key=lambda p: p.get("updated_at", ""), reverse=True)
|
||||
return result
|
||||
|
||||
def get_plan(self, filename: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Plan not found: {filename}")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_plan(self, filename: str, data: dict, index_image_base64: str | None = None) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
data = dict(data)
|
||||
data["updated_at"] = self._now_iso()
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if index_image_base64:
|
||||
self.save_index_image(filename, index_image_base64)
|
||||
|
||||
return {"filename": path.name, "updated_at": data["updated_at"]}
|
||||
|
||||
def save_index_image(self, filename: str, image_base64: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
stem = path.stem
|
||||
thumb_path = self.plan_dir / f"{stem}.png"
|
||||
|
||||
# Accept both raw base64 and data: URL forms
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(b64)
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
def save_index_image_to_output(self, image_base64: str, filename_prefix: str = "Lab_index") -> dict:
|
||||
"""Saves the contact-sheet index image into ComfyUI's own output folder,
|
||||
alongside the images generated during the run (unlike save_index_image,
|
||||
which saves it next to the plan file in plan_dir)."""
|
||||
b64 = image_base64
|
||||
if "," in b64 and b64.strip().lower().startswith("data:"):
|
||||
b64 = b64.split(",", 1)[1]
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
import folder_paths # type: ignore
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
|
||||
save_name = f"{filename}_{counter:05}_.png"
|
||||
save_path = Path(full_output_folder) / save_name
|
||||
save_path.write_bytes(image_bytes)
|
||||
return {"filename": save_name, "subfolder": subfolder}
|
||||
|
||||
def delete_plan(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
thumb_path = self.plan_dir / f"{path.stem}.png"
|
||||
if thumb_path.is_file():
|
||||
thumb_path.unlink()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""AI skill (.md system prompt) file management service."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"md"}
|
||||
|
||||
DEFAULT_SKILL_FILENAME = "svg-icon.md"
|
||||
|
||||
DEFAULT_SKILL_CONTENT = """---
|
||||
name: SVG Icon Generator
|
||||
description: Produces clean, minimal, valid SVG icons and simple illustrations.
|
||||
---
|
||||
|
||||
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
|
||||
|
||||
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
|
||||
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
|
||||
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
|
||||
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
|
||||
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
|
||||
|
||||
## Output rules
|
||||
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
|
||||
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
|
||||
- Output exactly one `<svg>...</svg>` element per code block.
|
||||
|
||||
## Structure rules
|
||||
- Always include a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
|
||||
- Use a small, round coordinate system (0-24 or 0-100).
|
||||
- Round coordinate values to at most 2 decimal places.
|
||||
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
|
||||
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
|
||||
|
||||
## Color rules
|
||||
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
|
||||
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
|
||||
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
|
||||
|
||||
## Accessibility
|
||||
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
|
||||
|
||||
## Security (mandatory - do not violate)
|
||||
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
|
||||
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
|
||||
|
||||
## Example 1 - simple geometric icon (single color, primitives only)
|
||||
|
||||
Request: "Sun icon"
|
||||
|
||||
Parts: disc (circle), 8 rays (lines).
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sun</title>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="1" x2="12" y2="4"/>
|
||||
<line x1="12" y1="20" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="4" y2="12"/>
|
||||
<line x1="20" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</g>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Example 2 - organic subject (multi-color, decomposed into parts)
|
||||
|
||||
Request: "Bird icon"
|
||||
|
||||
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Bird</title>
|
||||
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
|
||||
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
|
||||
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
|
||||
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
|
||||
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
|
||||
"""
|
||||
|
||||
SKILL_CREATOR_FILENAME = "skill-creator.md"
|
||||
|
||||
SKILL_CREATOR_CONTENT = """---
|
||||
name: Skill Creator
|
||||
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
|
||||
---
|
||||
|
||||
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter (`name`, `description`) followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
|
||||
|
||||
## How to conduct the interview
|
||||
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
|
||||
- Cover, in this order, only what is not already clear from context:
|
||||
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
|
||||
2. What output format is expected - plain prose, a specific fenced code block language, a fixed structure, a length limit?
|
||||
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
|
||||
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
|
||||
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
|
||||
|
||||
## Output format for the finished skill
|
||||
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
|
||||
- The block must start with frontmatter exactly in this shape:
|
||||
---
|
||||
name: <short display name, shown in the skill dropdown>
|
||||
description: <one sentence, shown as a tooltip in the dropdown>
|
||||
---
|
||||
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
|
||||
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
|
||||
"""
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
"""Extract a shallow `key: value` frontmatter block, if present."""
|
||||
m = _FRONTMATTER_RE.match(content)
|
||||
if not m:
|
||||
return {}
|
||||
fields: dict = {}
|
||||
for line in m.group(1).splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
class SkillService:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills_dir = skills_dir
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_default_skill()
|
||||
|
||||
def _seed_default_skill(self) -> None:
|
||||
"""Seed a starter skill file the first time the directory is empty."""
|
||||
try:
|
||||
has_any = any(
|
||||
p.is_file() and p.suffix.lstrip(".").lower() in ALLOWED_EXTS
|
||||
for p in self.skills_dir.iterdir()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
has_any = False
|
||||
if not has_any:
|
||||
(self.skills_dir / DEFAULT_SKILL_FILENAME).write_text(
|
||||
DEFAULT_SKILL_CONTENT, encoding="utf-8"
|
||||
)
|
||||
(self.skills_dir / SKILL_CREATOR_FILENAME).write_text(
|
||||
SKILL_CREATOR_CONTENT, encoding="utf-8"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> list[dict]:
|
||||
"""Return sorted list of skill files with parsed frontmatter metadata."""
|
||||
if not self.skills_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.skills_dir.glob("*")):
|
||||
if not (path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS):
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
content = ""
|
||||
meta = _parse_frontmatter(content)
|
||||
files.append({
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def save_file(self, filename: str, content: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
return {
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
# Skills are a flat directory - reject any path separator (no subfolders).
|
||||
name = filename.strip()
|
||||
if "/" in name or "\\" in name or name in (".", ".."):
|
||||
return None
|
||||
ext = Path(name).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.skills_dir / name
|
||||
@@ -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"
|
||||
|
||||
@@ -4,7 +4,7 @@ import jinja2
|
||||
from aiohttp import web
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR
|
||||
from .config import TEMPLATES_DIR, STATIC_DIR, DATA_DIR, WORKFLOWS_DIR, LAB_PLAN_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +41,22 @@ async def serve_workflow_file(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def serve_lab_plan_file(request: web.Request) -> web.Response:
|
||||
"""Serve files (index thumbnails) from the Lab plan directory."""
|
||||
filename = request.match_info.get("filename", "")
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
file_path = LAB_PLAN_DIR / filename
|
||||
if not file_path.is_file():
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
return web.FileResponse(file_path, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
})
|
||||
|
||||
|
||||
class WorkflowStudio:
|
||||
"""Main entry point for Workflow Studio plugin."""
|
||||
|
||||
@@ -57,15 +73,19 @@ class WorkflowStudio:
|
||||
# Dynamic workflow file serving (thumbnails etc.)
|
||||
app.router.add_get("/wfm_data/workflows/{filename}", serve_workflow_file)
|
||||
|
||||
# Dynamic Lab plan file serving (index thumbnails)
|
||||
app.router.add_get("/wfm_data/lab_plan/{filename}", serve_lab_plan_file)
|
||||
|
||||
# Main page
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
ollama_routes.setup_routes(app)
|
||||
unsloth_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
@@ -74,5 +94,7 @@ class WorkflowStudio:
|
||||
wildcard_routes.setup_routes(app)
|
||||
tagger_routes.setup_routes(app)
|
||||
gmic_routes.setup_routes(app)
|
||||
skill_routes.setup_routes(app)
|
||||
lab_routes.setup_routes(app)
|
||||
|
||||
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.71"
|
||||
version = "0.4.2"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -19,3 +19,6 @@ piexif>=1.1.3
|
||||
# Omit if you do not use DeepDanbooru models.
|
||||
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
|
||||
# tensorflow>=2.10
|
||||
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
+160
-1
@@ -2,11 +2,45 @@
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── サブタブ (Output / Style-Prompt) ──────────────────────── */
|
||||
|
||||
.wfm-gallery-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-gallery-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-gallery-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-gallery-panel.active { display: flex; }
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
@@ -499,6 +533,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-image-prompt-text {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -833,3 +881,114 @@
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ImagePrompt / Style_Catalog ギャラリー (右ペイン)
|
||||
============================================================ */
|
||||
|
||||
.wfm-imageprompt-detail-panel {
|
||||
width: 520px;
|
||||
min-width: 520px;
|
||||
max-width: 520px;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-left {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-detail-col-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-prompt-box {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-btn-row .wfm-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-chip-remove:hover { opacity: 1; }
|
||||
|
||||
.wfm-imageprompt-final-section {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-imageprompt-final-textarea {
|
||||
margin-top: 4px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
/* Lab tab — experimental I2I batch generation with per-column keyframes */
|
||||
|
||||
/* .wfm-gen-subtab-content.active defaults to flex-direction:row (main.css);
|
||||
Lab needs its Setting/Results nav stacked above the panel body. */
|
||||
#wfm-gen-subtab-lab.active {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-subtab-btn:hover { color: var(--wfm-text-primary); }
|
||||
.wfm-lab-subtab-btn.active { color: var(--wfm-primary); border-bottom-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100% - 33px);
|
||||
}
|
||||
|
||||
.wfm-lab-panel.active { display: flex; }
|
||||
|
||||
.wfm-lab-planjson-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 4px 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Setting layout: left | center (4 columns) | right ---------------- */
|
||||
|
||||
.wfm-lab-setting-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-pane:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-left { width: 220px; flex-shrink: 0; }
|
||||
.wfm-lab-center { flex: 1; padding: 0; }
|
||||
.wfm-lab-right { width: 260px; flex-shrink: 0; overflow-y: auto; }
|
||||
|
||||
.wfm-lab-side-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--wfm-bg-secondary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-plan-workflow-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-lab-field-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-lab-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------- Keyframe columns ---------------- */
|
||||
|
||||
.wfm-lab-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-column:last-child { border-right: none; }
|
||||
|
||||
.wfm-lab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
row-gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-col-btns { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-col-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Model column header: C/L/V toggle that switches which single field its cells
|
||||
summarize (see _modelViewMode in lab-tab.js) — not part of the Plan data. */
|
||||
.wfm-lab-model-view-toggle { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-model-view-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-model-view-btn:hover { color: var(--wfm-text-primary); border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-model-view-btn.active {
|
||||
color: #fff;
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-lab-col-cells {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-cell:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-cell-index {
|
||||
font-weight: 600;
|
||||
color: var(--wfm-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wfm-lab-cell-value {
|
||||
color: var(--wfm-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-revert .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Setting 1, not yet explicitly overridden: showing the live GenerateUI value instead of
|
||||
its own stored one (see _isLiveDisplay in lab-tab.js). Dashed border distinguishes it
|
||||
from a saved override (solid border) at a glance. */
|
||||
.wfm-lab-cell.wfm-lab-cell-live {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-live .wfm-lab-cell-value {
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* This keyframe row is skipped entirely (see _resolveKeyframe's bypassed handling) —
|
||||
amber to read as "temporarily disabled", distinct from the neutral gray used for
|
||||
revert/live. Takes priority over both of those in _cellHtml. */
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed {
|
||||
border-color: var(--wfm-warning);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wfm-lab-cell.wfm-lab-cell-bypassed .wfm-lab-cell-value {
|
||||
color: var(--wfm-warning);
|
||||
}
|
||||
|
||||
.wfm-lab-cell-applied {
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
border-top: 1px dashed var(--wfm-border);
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-cell-prompt-line {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Modal form (checkpoint/vae/prompt/ksampler edit) ---------------- */
|
||||
|
||||
.wfm-lab-modal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-row label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-btnrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-hint {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-style: italic;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-inline > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-lab-modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------------- Results layout ---------------- */
|
||||
|
||||
.wfm-lab-results-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-lab-results-left {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.wfm-lab-results-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-lab-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
border: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-result-thumb:hover { border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-result-item { position: relative; }
|
||||
|
||||
.wfm-lab-result-index {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
}
|
||||
@@ -511,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 {
|
||||
@@ -1044,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;
|
||||
@@ -2319,6 +2342,38 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Col3 (Wildcard/Style) タブ切り替え */
|
||||
.wfm-prompt-col3-tabs {
|
||||
display: flex;
|
||||
background: var(--wfm-surface);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-prompt-col3-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wfm-prompt-col3-tab:hover { color: var(--wfm-text); }
|
||||
.wfm-prompt-col3-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-prompt-col3-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ollama Assistant */
|
||||
.wfm-ollama-assistant {
|
||||
display: flex;
|
||||
@@ -2862,6 +2917,29 @@ body {
|
||||
Image Input (I2I) Slots
|
||||
============================================ */
|
||||
|
||||
/* Container/header styling comes from .wfm-settings-section / .wfm-settings-summary
|
||||
(shared details/summary accordion used by the Settings tab) — collapsed by default
|
||||
since this is a low-frequency feature. */
|
||||
|
||||
.wfm-i2i-placeholder-mode {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wfm-i2i-placeholder-mode label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-i2i-placeholder-fields input[type="color"] {
|
||||
padding: 2px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.wfm-i2i-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -2925,6 +3003,12 @@ body {
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
.wfm-i2i-drop-zone.wfm-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-i2i-drop-label {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
@@ -3159,6 +3243,21 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-help-article-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wfm-help-article-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--wfm-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfm-help-card {
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
@@ -5242,6 +5341,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);
|
||||
@@ -5253,6 +5355,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);
|
||||
@@ -5470,6 +5585,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;
|
||||
@@ -5546,6 +5684,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;
|
||||
@@ -5579,3 +5790,42 @@ body {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-skill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-skill-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-skill-panel {
|
||||
margin: 6px 8px 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
background: var(--wfm-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-ai-skill-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wfm-ai-skill-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
+722
-54
@@ -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, escapeHtml } 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";
|
||||
|
||||
@@ -47,33 +50,102 @@ function fileToBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
// ============================================
|
||||
// Thinking mode / Max tokens (shared by callLLM / callChat / callVLM)
|
||||
// ============================================
|
||||
|
||||
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
|
||||
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
|
||||
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
|
||||
// <think>タグ除去でも担保する(stripThinkingTagsを参照)。
|
||||
function _applyGenOptions(body, backend, settings) {
|
||||
const maxTokens = parseInt(settings?.maxTokens, 10);
|
||||
if (backend === "ollama") {
|
||||
body.think = !!settings?.thinkingMode;
|
||||
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
|
||||
} else if (maxTokens > 0) {
|
||||
body.max_tokens = maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function stripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).response || "";
|
||||
const text = (await res.json()).response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await res.json()).choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Unsloth proxy (server relays the request, attaching the API key from .env
|
||||
// so it never reaches the frontend — see py/routes/unsloth_routes.py)
|
||||
// ============================================
|
||||
|
||||
async function unslothProxy(baseUrl, path, method, payload) {
|
||||
const res = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
|
||||
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
|
||||
// `reasoning_content` field. Fold it back into a <think> block so the
|
||||
// existing Thinking-mode show/strip logic (_applyGenOptions/stripThinkingTags)
|
||||
// still applies uniformly. Without this, a low max_tokens can make the model
|
||||
// spend its whole budget reasoning and return an empty `content` with no
|
||||
// visible explanation.
|
||||
function _unslothContent(message) {
|
||||
const reasoning = message?.reasoning_content;
|
||||
const content = message?.content || "";
|
||||
return reasoning ? `<think>${reasoning}</think>${content}` : content;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// URL validation
|
||||
// ============================================
|
||||
@@ -97,6 +169,9 @@ async function fetchModels(url, backend) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models || []).map((m) => m.name);
|
||||
} else if (backend === "unsloth") {
|
||||
const data = await unslothProxy(url, "/v1/models", "GET");
|
||||
return (data.data || []).map((m) => m.id);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
@@ -111,30 +186,37 @@ async function testConnection(url, backend) {
|
||||
return models.length;
|
||||
}
|
||||
|
||||
async function callLLM(url, backend, model, prompt) {
|
||||
async function callLLM(url, backend, model, prompt, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.response || "";
|
||||
const text = data.response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content || "";
|
||||
const text = data.choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +224,41 @@ async function callLLM(url, backend, model, prompt) {
|
||||
// Translation prompt builder
|
||||
// ============================================
|
||||
|
||||
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
|
||||
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
|
||||
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
|
||||
// Uses a system role (via callChat) rather than a single raw prompt string —
|
||||
// local models follow "translate only, no commentary" far more reliably
|
||||
// when it's a system instruction instead of buried inside the user text.
|
||||
function buildTranslationMessages(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "").trim() : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English").trim() || "English" : LANG_NAMES[dstLang];
|
||||
const fromPart = srcName ? ` from ${srcName}` : "";
|
||||
const system = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
|
||||
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
|
||||
+ `no surrounding quotes, and do not repeat or echo the original text.`;
|
||||
return [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: text },
|
||||
];
|
||||
}
|
||||
|
||||
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
|
||||
function cleanTranslationOutput(raw) {
|
||||
let s = (raw || "").trim();
|
||||
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
|
||||
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
|
||||
if (s.length >= 2) {
|
||||
const first = s[0], last = s[s.length - 1];
|
||||
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
|
||||
if (pairs[first] === last) s = s.slice(1, -1).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Heuristic: if the cleaned output is (near-)identical to the input, the
|
||||
// model most likely echoed the source instead of translating it.
|
||||
function looksUntranslated(input, output) {
|
||||
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
|
||||
const a = normalize(input), b = normalize(output);
|
||||
return a.length > 0 && a === b;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -196,7 +308,7 @@ function initTranslateTab() {
|
||||
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
|
||||
if (!isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -214,11 +326,19 @@ function initTranslateTab() {
|
||||
outputEl.value = "";
|
||||
|
||||
try {
|
||||
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
outputEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
const messages = buildTranslationMessages(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const { content } = await callChat(url, backend, model, messages, undefined, settings);
|
||||
const cleaned = cleanTranslationOutput(content);
|
||||
outputEl.value = cleaned;
|
||||
|
||||
if (srcLangSel.value !== dstLangSel.value && looksUntranslated(text, cleaned)) {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastMaybeNotTranslated"), "error");
|
||||
} else {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
@@ -285,12 +405,16 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
// Update URL placeholder when backend changes
|
||||
const thinkingModeCheckbox = document.getElementById("wfm-ai-thinking-mode-checkbox");
|
||||
if (thinkingModeCheckbox) thinkingModeCheckbox.checked = !!saved.thinkingMode;
|
||||
const maxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (maxTokensInput && saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
if (!urlInput) return;
|
||||
const defaultUrl = r.value === "ollama" ? "http://localhost:11434" : "http://localhost:1234";
|
||||
if (!saved.backendUrl) urlInput.value = defaultUrl;
|
||||
urlInput.value = getAiBackendDefaultUrl(r.value);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,6 +465,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 +513,24 @@ 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 || "";
|
||||
const thinkingMode = !!thinkingModeCheckbox?.checked;
|
||||
const maxTokens = Math.max(0, parseInt(maxTokensInput?.value, 10) || 0);
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
thinkingMode, maxTokens,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
showToast(t("aiToastSettingsSaved"), "success");
|
||||
});
|
||||
|
||||
@@ -368,30 +544,440 @@ 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, settings = {}) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
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 || {};
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const message = data.choices?.[0]?.message || {};
|
||||
const content = _unslothContent(message);
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
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 || {};
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(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);
|
||||
}
|
||||
|
||||
// アシスタント応答から ```skill フェンスを抽出する(Skill Creator用スキルが出力する完成品ブロック)
|
||||
function _extractSkillBlock(text) {
|
||||
if (!text) return null;
|
||||
const m = text.match(/```skill\s*([\s\S]*?)```/i);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// frontmatterの name から保存用ファイル名を推測する(未設定時は my-skill.md)
|
||||
function _slugifySkillName(content) {
|
||||
const m = content.match(/^---\r?\n[\s\S]*?name:\s*(.+?)\r?\n/);
|
||||
const name = (m ? m[1] : "").trim();
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return `${slug || "my-skill"}.md`;
|
||||
}
|
||||
|
||||
// 応答内にスキル定義ブロックがあれば「スキルとして保存」ボタンを追加する。
|
||||
// クリックすると管理パネルを開き、新規スキルとしてエディタに内容を流し込む(保存前にレビュー可能)。
|
||||
function _appendSkillSaveButton(container, skillContent) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-btn wfm-btn-sm";
|
||||
btn.textContent = t("aiChatSaveAsSkill");
|
||||
btn.addEventListener("click", () => {
|
||||
const panel = document.getElementById("wfm-ai-skill-panel");
|
||||
if (panel) panel.style.display = "";
|
||||
skillOpenEditor(null, skillContent);
|
||||
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
|
||||
if (nameInput) nameInput.value = _slugifySkillName(skillContent);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI skills (Chat system prompt library)
|
||||
// ============================================
|
||||
|
||||
async function skillFetchFiles() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/skills");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function skillFetchContent(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/skills/content?filename=${encodeURIComponent(filename)}`);
|
||||
const data = await res.json();
|
||||
return data.content ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function skillSaveFile(filename, content) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/skills/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, content }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.status === "ok" ? data.file : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function skillDeleteFile(filename) {
|
||||
try {
|
||||
await fetch("/api/wfm/skills/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// system prompt として送る前に frontmatter (---...---) を取り除く
|
||||
function _stripSkillFrontmatter(content) {
|
||||
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
||||
return (m ? content.slice(m[0].length) : content).trim();
|
||||
}
|
||||
|
||||
let skillFiles = [];
|
||||
let skillEditingFilename = null; // null = new file
|
||||
let _activeSkillCache = null; // { filename, content } キャッシュ(送信毎の再フェッチを避ける)
|
||||
|
||||
function skillRenderList() {
|
||||
const list = document.getElementById("wfm-ai-skill-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (skillFiles.length === 0) {
|
||||
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">${t("aiSkillNoFiles")}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const f of skillFiles) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span class="wfm-wc-file-name" title="${escapeHtml(f.description || "")}">${escapeHtml(f.name)}</span>
|
||||
<span class="wfm-wc-file-ext">.md</span>
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-ai-skill-edit-btn" title="Edit">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-ai-skill-edit-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const content = await skillFetchContent(f.filename);
|
||||
skillOpenEditor(f.filename, content ?? "");
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function skillOpenEditor(filename, content) {
|
||||
skillEditingFilename = filename || null;
|
||||
|
||||
const editor = document.getElementById("wfm-ai-skill-editor");
|
||||
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
|
||||
const contentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
const deleteBtn = document.getElementById("wfm-ai-skill-editor-delete-btn");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = filename || "";
|
||||
contentTA.value = content || "";
|
||||
deleteBtn.style.display = filename ? "" : "none";
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function skillCloseEditor() {
|
||||
skillEditingFilename = null;
|
||||
const editor = document.getElementById("wfm-ai-skill-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
function skillPopulateSelect() {
|
||||
const sel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
if (!sel) return;
|
||||
const settings = loadAiSettings();
|
||||
const wanted = sel.value || settings.activeSkillFilename || "";
|
||||
|
||||
sel.innerHTML = `<option value="">-- ${t("aiSkillNone")} --</option>` +
|
||||
skillFiles.map((f) => `<option value="${escapeHtml(f.filename)}" title="${escapeHtml(f.description || "")}">${escapeHtml(f.name)}</option>`).join("");
|
||||
|
||||
sel.value = skillFiles.some((f) => f.filename === wanted) ? wanted : "";
|
||||
_activeSkillCache = null;
|
||||
}
|
||||
|
||||
async function skillRefreshFiles() {
|
||||
skillFiles = await skillFetchFiles();
|
||||
skillRenderList();
|
||||
skillPopulateSelect();
|
||||
}
|
||||
|
||||
// 選択中スキルの本文を取得(system prompt注入用、選択が変わるまでキャッシュ)
|
||||
async function _getActiveSkillSystemPrompt() {
|
||||
const sel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
const filename = sel?.value || "";
|
||||
if (!filename) return null;
|
||||
if (_activeSkillCache?.filename === filename) return _activeSkillCache.content;
|
||||
const raw = await skillFetchContent(filename);
|
||||
const content = _stripSkillFrontmatter(raw || "");
|
||||
_activeSkillCache = { filename, content };
|
||||
return content;
|
||||
}
|
||||
|
||||
function initSkillManager() {
|
||||
const selectEl = document.getElementById("wfm-ai-chat-skill-select");
|
||||
const manageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
|
||||
const panel = document.getElementById("wfm-ai-skill-panel");
|
||||
const closeBtn = document.getElementById("wfm-ai-skill-close-btn");
|
||||
const newBtn = document.getElementById("wfm-ai-skill-new-btn");
|
||||
const saveBtn = document.getElementById("wfm-ai-skill-editor-save-btn");
|
||||
const deleteBtn = document.getElementById("wfm-ai-skill-editor-delete-btn");
|
||||
const cancelBtn = document.getElementById("wfm-ai-skill-editor-cancel-btn");
|
||||
|
||||
if (!selectEl) return;
|
||||
|
||||
skillRefreshFiles();
|
||||
|
||||
selectEl.addEventListener("change", () => {
|
||||
saveAiSettings({ activeSkillFilename: selectEl.value });
|
||||
_activeSkillCache = null;
|
||||
});
|
||||
|
||||
manageBtn?.addEventListener("click", () => {
|
||||
if (!panel) return;
|
||||
const show = panel.style.display === "none";
|
||||
panel.style.display = show ? "" : "none";
|
||||
if (show) skillRefreshFiles();
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener("click", () => {
|
||||
if (panel) panel.style.display = "none";
|
||||
});
|
||||
|
||||
newBtn?.addEventListener("click", () => {
|
||||
skillOpenEditor(null, "---\nname: \ndescription: \n---\n\n");
|
||||
});
|
||||
|
||||
saveBtn?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-ai-skill-editor-filename");
|
||||
const contentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
let filename = (nameInput?.value || "").trim();
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!filename) { showToast(t("pleaseEnterFilename"), "error"); return; }
|
||||
if (!/\.md$/i.test(filename)) filename += ".md";
|
||||
if (!/^[\w\-. ]+\.md$/i.test(filename)) { showToast(t("invalidPathFormat"), "error"); return; }
|
||||
|
||||
const saved = await skillSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(t("savedAs", filename), "success");
|
||||
skillCloseEditor();
|
||||
await skillRefreshFiles();
|
||||
selectEl.value = filename;
|
||||
saveAiSettings({ activeSkillFilename: filename });
|
||||
_activeSkillCache = null;
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
deleteBtn?.addEventListener("click", async () => {
|
||||
if (!skillEditingFilename) return;
|
||||
if (!confirm(`Delete "${skillEditingFilename}"?`)) return;
|
||||
await skillDeleteFile(skillEditingFilename);
|
||||
showToast(t("deletedName", skillEditingFilename), "success");
|
||||
if (selectEl.value === skillEditingFilename) {
|
||||
saveAiSettings({ activeSkillFilename: "" });
|
||||
_activeSkillCache = null;
|
||||
}
|
||||
skillCloseEditor();
|
||||
await skillRefreshFiles();
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener("click", () => skillCloseEditor());
|
||||
}
|
||||
|
||||
function appendChatBubble(messagesEl, role, content, opts = {}) {
|
||||
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);
|
||||
const skillBlock = _extractSkillBlock(content);
|
||||
if (skillBlock) _appendSkillSaveButton(div, skillBlock);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
@@ -403,9 +989,24 @@ 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();
|
||||
});
|
||||
|
||||
initSkillManager();
|
||||
|
||||
let chatHistory = [];
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -414,7 +1015,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 +1028,43 @@ 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 skillPrompt = await _getActiveSkillSystemPrompt();
|
||||
const messagesToSend = skillPrompt
|
||||
? [{ role: "system", content: skillPrompt }, ...chatHistory]
|
||||
: chatHistory;
|
||||
const reply = await callChat(url, backend, model, messagesToSend, tools, settings);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
|
||||
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 +1096,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 +1151,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 +1161,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 +1175,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; }
|
||||
@@ -539,7 +1203,7 @@ function initVlmTab() {
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
|
||||
try {
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
const result = await callLLM(url, backend, model, prompt, settings);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
@@ -557,7 +1221,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 +1229,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, settings);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
@@ -595,3 +1259,7 @@ export function initAiTab() {
|
||||
initVlmTab();
|
||||
initSettingsTab();
|
||||
}
|
||||
|
||||
// gallery-tab.js の外部(Comic Creator)向けブリッジ(半自動マンガ作成の画像プロンプト
|
||||
// 下書き機能)が、設定タブと同じAI接続設定・LLM呼び出しロジックを再利用するためのexport。
|
||||
export { callLLM, loadAiSettings, isValidBackendUrl };
|
||||
|
||||
+215
-4
@@ -222,6 +222,68 @@ function applyI18nToHtml() {
|
||||
const galleryOpenTaggerBtn = document.getElementById("wfm-gallery-open-tagger-btn");
|
||||
if (galleryOpenTaggerBtn) galleryOpenTaggerBtn.textContent = t("tabTagger");
|
||||
|
||||
// Gallery サブタブ (Output / ImagePrompt / Style_Catalog)
|
||||
const galleryOutputSubtab = document.getElementById("wfm-gallery-subtab-output");
|
||||
if (galleryOutputSubtab) galleryOutputSubtab.textContent = t("galleryTabOutput");
|
||||
const galleryImagePromptSubtab = document.getElementById("wfm-gallery-subtab-imageprompt");
|
||||
if (galleryImagePromptSubtab) galleryImagePromptSubtab.textContent = t("galleryTabImagePrompt");
|
||||
const galleryStyleCatalogSubtab = document.getElementById("wfm-gallery-subtab-styleCatalog");
|
||||
if (galleryStyleCatalogSubtab) galleryStyleCatalogSubtab.textContent = t("galleryTabStyleCatalog");
|
||||
|
||||
// ImagePrompt ギャラリーパネル
|
||||
const imagePromptSearch = document.getElementById("wfm-imageprompt-search");
|
||||
if (imagePromptSearch) imagePromptSearch.placeholder = t("gallerySearchPlaceholder");
|
||||
const imagePromptRefreshBtn = document.getElementById("wfm-imageprompt-refresh-btn");
|
||||
if (imagePromptRefreshBtn) imagePromptRefreshBtn.textContent = t("refresh");
|
||||
const imagePromptTreeTitle = document.getElementById("wfm-imageprompt-tree-title");
|
||||
if (imagePromptTreeTitle) imagePromptTreeTitle.textContent = t("galleryFolders");
|
||||
const imagePromptListPlaceholder = document.getElementById("wfm-imageprompt-list-placeholder");
|
||||
if (imagePromptListPlaceholder) imagePromptListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const imagePromptNoSelection = document.getElementById("wfm-imageprompt-no-selection");
|
||||
if (imagePromptNoSelection) imagePromptNoSelection.textContent = t("galleryNoSelection");
|
||||
const imagePromptSelectedPromptTitle = document.getElementById("wfm-imageprompt-selected-prompt-title");
|
||||
if (imagePromptSelectedPromptTitle) imagePromptSelectedPromptTitle.textContent = t("imagePromptSelectedPrompt");
|
||||
const imagePromptSelectedPrompt = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
if (imagePromptSelectedPrompt) imagePromptSelectedPrompt.placeholder = t("imagePromptNoPrompt");
|
||||
const imagePromptSavePromptBtn = document.getElementById("wfm-imageprompt-save-prompt-btn");
|
||||
if (imagePromptSavePromptBtn) imagePromptSavePromptBtn.textContent = t("save");
|
||||
const imagePromptAddBtn = document.getElementById("wfm-imageprompt-add-btn");
|
||||
if (imagePromptAddBtn) imagePromptAddBtn.textContent = t("imagePromptAddBtn");
|
||||
const imagePromptChipsTitle = document.getElementById("wfm-imageprompt-chips-title");
|
||||
if (imagePromptChipsTitle) imagePromptChipsTitle.textContent = t("imagePromptChipsTitle");
|
||||
const imagePromptClearBtn = document.getElementById("wfm-imageprompt-clear-btn");
|
||||
if (imagePromptClearBtn) imagePromptClearBtn.textContent = t("imagePromptClearBtn");
|
||||
const imagePromptFinalPromptTitle = document.getElementById("wfm-imageprompt-final-prompt-title");
|
||||
if (imagePromptFinalPromptTitle) imagePromptFinalPromptTitle.textContent = t("imagePromptFinalPromptTitle");
|
||||
const imagePromptCopyBtn = document.getElementById("wfm-imageprompt-copy-btn");
|
||||
if (imagePromptCopyBtn) imagePromptCopyBtn.textContent = t("copy");
|
||||
|
||||
// Style_Catalog ギャラリーパネル
|
||||
const scSearch = document.getElementById("wfm-stylecatalog-search");
|
||||
if (scSearch) scSearch.placeholder = t("stylecatalogSearchPlaceholder");
|
||||
const scRefreshBtn = document.getElementById("wfm-stylecatalog-refresh-btn");
|
||||
if (scRefreshBtn) scRefreshBtn.textContent = t("refresh");
|
||||
const scTreeTitle = document.getElementById("wfm-stylecatalog-tree-title");
|
||||
if (scTreeTitle) scTreeTitle.textContent = t("galleryFolders");
|
||||
const scListPlaceholder = document.getElementById("wfm-stylecatalog-list-placeholder");
|
||||
if (scListPlaceholder) scListPlaceholder.textContent = t("gallerySelectFolder");
|
||||
const scNoSelection = document.getElementById("wfm-stylecatalog-no-selection");
|
||||
if (scNoSelection) scNoSelection.textContent = t("galleryNoSelection");
|
||||
const scPositiveTitle = document.getElementById("wfm-stylecatalog-positive-title");
|
||||
if (scPositiveTitle) scPositiveTitle.textContent = t("stylecatalogPositive");
|
||||
const scCopyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
if (scCopyPositiveBtn) scCopyPositiveBtn.textContent = t("copy");
|
||||
const scNegativeTitle = document.getElementById("wfm-stylecatalog-negative-title");
|
||||
if (scNegativeTitle) scNegativeTitle.textContent = t("stylecatalogNegative");
|
||||
const scCopyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
if (scCopyNegativeBtn) scCopyNegativeBtn.textContent = t("copy");
|
||||
const scSelectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
if (scSelectStyleBtn) scSelectStyleBtn.textContent = t("stylecatalogSelectStyleBtn");
|
||||
const scLoadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
if (scLoadGenuiBtn) scLoadGenuiBtn.textContent = t("stylecatalogLoadGenuiBtn");
|
||||
const scOpenMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
if (scOpenMetadataBtn) scOpenMetadataBtn.textContent = t("stylecatalogOpenMetadataBtn");
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
@@ -302,6 +364,14 @@ function applyI18nToHtml() {
|
||||
}
|
||||
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
|
||||
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
|
||||
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
|
||||
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
|
||||
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
|
||||
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
|
||||
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
|
||||
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
|
||||
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
|
||||
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
|
||||
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
|
||||
@@ -310,6 +380,29 @@ 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");
|
||||
const aiSkillSel = document.getElementById("wfm-ai-chat-skill-select");
|
||||
if (aiSkillSel) {
|
||||
const firstOpt = aiSkillSel.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = `-- ${t("aiSkillNone")} --`;
|
||||
}
|
||||
const aiSkillManageBtn = document.getElementById("wfm-ai-chat-skill-manage-btn");
|
||||
if (aiSkillManageBtn) aiSkillManageBtn.title = t("aiSkillManageBtnTitle");
|
||||
const aiSkillPanelTitle = document.querySelector(".wfm-ai-skill-panel-title");
|
||||
if (aiSkillPanelTitle) aiSkillPanelTitle.textContent = t("aiSkillPanelTitle");
|
||||
const aiSkillNewBtn = document.getElementById("wfm-ai-skill-new-btn");
|
||||
if (aiSkillNewBtn) aiSkillNewBtn.textContent = t("aiSkillNewBtn");
|
||||
const aiSkillContentTA = document.getElementById("wfm-ai-skill-editor-content");
|
||||
if (aiSkillContentTA) aiSkillContentTA.placeholder = t("aiSkillContentPlaceholder");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -357,6 +450,11 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-15": "helpGen15", "wfm-help-gen-16": "helpGen16",
|
||||
"wfm-help-gen-17": "helpGen17", "wfm-help-gen-18": "helpGen18",
|
||||
"wfm-help-gen-19": "helpGen19",
|
||||
"wfm-help-gen-20": "helpGen20", "wfm-help-gen-21": "helpGen21",
|
||||
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
|
||||
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
|
||||
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
|
||||
"wfm-help-gen-28": "helpGen28",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
@@ -371,18 +469,43 @@ function applyI18nToHtml() {
|
||||
"wfm-help-feeder-gal-1": "helpFeederGal1", "wfm-help-feeder-gal-2": "helpFeederGal2",
|
||||
"wfm-help-feeder-gal-3": "helpFeederGal3", "wfm-help-feeder-gal-4": "helpFeederGal4",
|
||||
"wfm-help-feeder-gal-5": "helpFeederGal5", "wfm-help-feeder-gal-6": "helpFeederGal6",
|
||||
"wfm-help-lab-title": "helpLabTitle",
|
||||
"wfm-help-lab-desc": "helpLabDesc",
|
||||
"wfm-help-lab-card-columns-title": "helpLabCardColumnsTitle",
|
||||
"wfm-help-lab-1": "helpLab1", "wfm-help-lab-2": "helpLab2",
|
||||
"wfm-help-lab-3": "helpLab3", "wfm-help-lab-4": "helpLab4",
|
||||
"wfm-help-lab-5": "helpLab5", "wfm-help-lab-6": "helpLab6",
|
||||
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
|
||||
"wfm-help-lab-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
|
||||
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
|
||||
"wfm-help-lab-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
|
||||
"wfm-help-lab-15": "helpLab15",
|
||||
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
|
||||
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
|
||||
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
|
||||
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
|
||||
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
|
||||
"wfm-help-lab-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
|
||||
"wfm-help-lab-plan-11": "helpLabPlan11",
|
||||
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
|
||||
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
|
||||
"wfm-help-lab-results-3": "helpLabResults3",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-prompt-card-style-title": "helpPromptCardStyleTitle",
|
||||
"wfm-help-prompt-10": "helpPrompt10", "wfm-help-prompt-11": "helpPrompt11",
|
||||
"wfm-help-prompt-12": "helpPrompt12", "wfm-help-prompt-13": "helpPrompt13",
|
||||
"wfm-help-prompt-14": "helpPrompt14",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13",
|
||||
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
|
||||
"wfm-help-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",
|
||||
@@ -403,6 +526,12 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gallery-13": "helpGallery13", "wfm-help-gallery-14": "helpGallery14",
|
||||
"wfm-help-gallery-15": "helpGallery15", "wfm-help-gallery-16": "helpGallery16",
|
||||
"wfm-help-gallery-17": "helpGallery17",
|
||||
"wfm-help-gallery-18": "helpGallery18", "wfm-help-gallery-19": "helpGallery19",
|
||||
"wfm-help-gallery-20": "helpGallery20", "wfm-help-gallery-21": "helpGallery21",
|
||||
"wfm-help-gallery-22": "helpGallery22", "wfm-help-gallery-23": "helpGallery23",
|
||||
"wfm-help-gallery-24": "helpGallery24", "wfm-help-gallery-25": "helpGallery25",
|
||||
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
"wfm-help-tagger-1": "helpTagger1", "wfm-help-tagger-2": "helpTagger2",
|
||||
"wfm-help-tagger-3": "helpTagger3", "wfm-help-tagger-4": "helpTagger4",
|
||||
@@ -422,7 +551,18 @@ function applyI18nToHtml() {
|
||||
"wfm-help-sidepanel-17": "helpSidepanel17",
|
||||
"wfm-help-ai-title": "helpAiTitle",
|
||||
"wfm-help-ai-1": "helpAi1", "wfm-help-ai-2": "helpAi2", "wfm-help-ai-3": "helpAi3",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-5": "helpAi5", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-4": "helpAi4", "wfm-help-ai-6": "helpAi6",
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-ai-8": "helpAi8",
|
||||
"wfm-help-ai-9": "helpAi9",
|
||||
"wfm-help-ai-card-layout-title": "helpAiCardLayout",
|
||||
"wfm-help-ai-card-translate-title": "helpAiCardTranslate",
|
||||
"wfm-help-ai-card-chat-title": "helpAiCardChat",
|
||||
"wfm-help-ai-card-svg-title": "helpAiCardSvg",
|
||||
"wfm-help-ai-card-skills-title": "helpAiCardSkills",
|
||||
"wfm-help-ai-card-skilltips-title": "helpAiCardSkillTips",
|
||||
"wfm-help-ai-card-tools-title": "helpAiCardTools",
|
||||
"wfm-help-ai-card-settings-title": "helpAiCardSettings",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
@@ -438,6 +578,7 @@ function applyI18nToHtml() {
|
||||
"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-19b": "helpImageEdit19b",
|
||||
"wfm-help-imageedit-20": "helpImageEdit20", "wfm-help-imageedit-21": "helpImageEdit21",
|
||||
"wfm-help-imageedit-22": "helpImageEdit22", "wfm-help-imageedit-23": "helpImageEdit23",
|
||||
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
|
||||
@@ -463,6 +604,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
|
||||
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
|
||||
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
|
||||
"wfm-help-imageedit-mask-14": "helpImageEditMask14",
|
||||
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
|
||||
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
|
||||
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
|
||||
@@ -482,12 +624,68 @@ 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-trouble-9": "helpTrouble9",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
"wfm-help-kofi-desc": "helpKofiDesc",
|
||||
"wfm-help-thanks": "helpThanks",
|
||||
// -- Help card headings (card-based redesign) --
|
||||
"wfm-help-about-card-getting-started-title": "helpAboutCardGettingStarted",
|
||||
"wfm-help-wf-title": "helpWfTitle",
|
||||
"wfm-help-wf-card-browse-title": "helpWfCardBrowse",
|
||||
"wfm-help-wf-card-groups-title": "helpWfCardGroups",
|
||||
"wfm-help-wf-card-panel-title": "helpWfCardPanel",
|
||||
"wfm-help-gen-title": "helpGenTitle",
|
||||
"wfm-help-gen-card-connect-title": "helpGenCardConnect",
|
||||
"wfm-help-gen-card-tabs-title": "helpGenCardTabs",
|
||||
"wfm-help-gen-card-seed-title": "helpGenCardSeed",
|
||||
"wfm-help-gen-card-batchsetup-title": "helpGenCardBatchSetup",
|
||||
"wfm-help-gen-card-batchtypes-title": "helpGenCardBatchTypes",
|
||||
"wfm-help-gen-card-lora-title": "helpGenCardLora",
|
||||
"wfm-help-prompt-title": "helpPromptTitle",
|
||||
"wfm-help-prompt-card-ai-title": "helpPromptCardAi",
|
||||
"wfm-help-prompt-card-presets-title": "helpPromptCardPresets",
|
||||
"wfm-help-prompt-card-wildcards-title": "helpPromptCardWildcards",
|
||||
"wfm-help-metadata-card-overview-title": "helpMetadataCardOverview",
|
||||
"wfm-help-metadata-card-extracted-title": "helpMetadataCardExtracted",
|
||||
"wfm-help-metadata-card-formats-title": "helpMetadataCardFormats",
|
||||
"wfm-help-settings-title": "helpSettingsTitle",
|
||||
"wfm-help-settings-card-general-title": "helpSettingsCardGeneral",
|
||||
"wfm-help-settings-card-connections-title": "helpSettingsCardConnections",
|
||||
"wfm-help-settings-card-workflow-title": "helpSettingsCardWorkflow",
|
||||
"wfm-help-settings-card-editor-title": "helpSettingsCardEditor",
|
||||
"wfm-help-settings-card-misc-title": "helpSettingsCardMisc",
|
||||
"wfm-help-nodes-title": "helpNodesTitle",
|
||||
"wfm-help-nodes-card-browse-title": "helpNodesCardBrowse",
|
||||
"wfm-help-nodes-card-groups-title": "helpNodesCardGroups",
|
||||
"wfm-help-nodes-card-panel-title": "helpNodesCardPanel",
|
||||
"wfm-help-gallery-card-browse-title": "helpGalleryCardBrowse",
|
||||
"wfm-help-gallery-card-folder-title": "helpGalleryCardFolder",
|
||||
"wfm-help-gallery-card-detail-title": "helpGalleryCardDetail",
|
||||
"wfm-help-gallery-card-actions-title": "helpGalleryCardActions",
|
||||
"wfm-help-gallery-card-formats-title": "helpGalleryCardFormats",
|
||||
"wfm-help-models-title": "helpModelsTitle",
|
||||
"wfm-help-models-card-browse-title": "helpModelsCardBrowse",
|
||||
"wfm-help-models-card-civitai-title": "helpModelsCardCivitai",
|
||||
"wfm-help-models-card-apply-title": "helpModelsCardApply",
|
||||
"wfm-help-models-card-groups-title": "helpModelsCardGroups",
|
||||
"wfm-help-models-card-bulk-title": "helpModelsCardBulk",
|
||||
"wfm-help-sidepanel-card-tabs-title": "helpSidepanelCardTabs",
|
||||
"wfm-help-sidepanel-card-dnd-title": "helpSidepanelCardDnd",
|
||||
"wfm-help-sidepanel-card-info-title": "helpSidepanelCardInfo",
|
||||
"wfm-help-sidepanel-card-ai-title": "helpSidepanelCardAi",
|
||||
"wfm-help-sidepanel-card-nav-title": "helpSidepanelCardNav",
|
||||
"wfm-help-tagger-card-overview-title": "helpTaggerCardOverview",
|
||||
"wfm-help-tagger-card-single-title": "helpTaggerCardSingle",
|
||||
"wfm-help-tagger-card-sending-title": "helpTaggerCardSending",
|
||||
"wfm-help-tagger-card-batch-title": "helpTaggerCardBatch",
|
||||
"wfm-help-imageedit-card-overview-title": "helpImageEditCardOverview",
|
||||
"wfm-help-shortcuts-card-general-title": "helpShortcutsCardGeneral",
|
||||
"wfm-help-trouble-card-connection-title": "helpTroubleCardConnection",
|
||||
"wfm-help-trouble-card-workflow-title": "helpTroubleCardWorkflow",
|
||||
"wfm-help-trouble-card-tagger-title": "helpTroubleCardTagger",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
@@ -500,6 +698,11 @@ function applyI18nToHtml() {
|
||||
helpSearch.placeholder = t("helpSearchPlaceholder");
|
||||
helpSearch.addEventListener("input", _onHelpSearch);
|
||||
}
|
||||
|
||||
// Jump-to-tab buttons
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.textContent = t("helpJumpToTab");
|
||||
});
|
||||
}
|
||||
|
||||
function _onHelpSearch(e) {
|
||||
@@ -565,7 +768,7 @@ import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
import { initMetadataTab } from "./metadata-tab.js";
|
||||
@@ -581,6 +784,7 @@ try {
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
applyJsonColors(_s.jsonColors);
|
||||
applyModelTabActiveColor(_s.modelTabActiveColor);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
@@ -593,6 +797,13 @@ function initHelpTab() {
|
||||
document.getElementById(`wfm-help-page-${page}`)?.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Jump-to-tab buttons: switch to the real feature tab this help page documents
|
||||
document.querySelectorAll(".wfm-help-jump-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelector(`.wfm-tab[data-tab="${btn.dataset.jumpTab}"]`)?.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
+66
-10
@@ -18,8 +18,15 @@ export const comfyUI = {
|
||||
connected: false,
|
||||
generating: false,
|
||||
currentPromptId: null,
|
||||
// trackProgress() 実行中の Promise を promptId -> reject関数 で保持。
|
||||
// WebSocket切断時に、待機中のPromiseを解決させずに放置してバッチループが
|
||||
// 無言のまま停止する(例: カタログ作成バッチが特定件数で止まる)のを防ぐ。
|
||||
_pendingTrackers: new Map(),
|
||||
currentWorkflow: null,
|
||||
currentAnalysis: null,
|
||||
// ワークフロー読み込み時点のLora Loader (LoraManager)ノードの loras/text 状態。
|
||||
// Stackグループチェックボックスのトグル時に、OFF側の「読み込み時の状態へ戻す」用に使う。
|
||||
loraManagerSnapshots: {},
|
||||
|
||||
updateUrl(url) {
|
||||
if (!url) {
|
||||
@@ -66,10 +73,18 @@ export const comfyUI = {
|
||||
this.socket = new WebSocket(url);
|
||||
this.socket.onopen = () => resolve(true);
|
||||
this.socket.onerror = () => resolve(false);
|
||||
this.socket.onclose = () => { this.socket = null; };
|
||||
this.socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this._rejectPendingTrackers(new Error("WebSocket disconnected"));
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_rejectPendingTrackers(err) {
|
||||
for (const rejectFn of this._pendingTrackers.values()) rejectFn(err);
|
||||
this._pendingTrackers.clear();
|
||||
},
|
||||
|
||||
// Node info fetching
|
||||
async fetchObjectInfo(nodeClass) {
|
||||
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
|
||||
@@ -196,12 +211,29 @@ export const comfyUI = {
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
trackProgress(promptId, progressCallback) {
|
||||
// timeoutMs: この時間内に完了/エラー/中断通知が来なければ強制的にreject。
|
||||
// WebSocketの取りこぼしやexecution_interrupted未処理でPromiseが永久に
|
||||
// ぶら下がり続け、バッチループが無言で停止するのを防ぐための安全弁。
|
||||
trackProgress(promptId, progressCallback, timeoutMs = 10 * 60 * 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error("WebSocket not connected"));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (isResolve, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.socket?.removeEventListener("message", handler);
|
||||
clearTimeout(timer);
|
||||
this._pendingTrackers.delete(promptId);
|
||||
if (isResolve) resolve(value); else reject(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(false, new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s (prompt_id=${promptId})`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handler = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
@@ -211,17 +243,19 @@ export const comfyUI = {
|
||||
}
|
||||
if (msg.type === "executing" && msg.data?.prompt_id === promptId) {
|
||||
if (msg.data.node === null) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
resolve();
|
||||
finish(true);
|
||||
}
|
||||
}
|
||||
if (msg.type === "execution_error" && msg.data?.prompt_id === promptId) {
|
||||
this.socket.removeEventListener("message", handler);
|
||||
reject(new Error(msg.data.exception_message || "Execution error"));
|
||||
finish(false, new Error(msg.data.exception_message || "Execution error"));
|
||||
}
|
||||
if (msg.type === "execution_interrupted" && msg.data?.prompt_id === promptId) {
|
||||
finish(false, new Error("Execution interrupted"));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
this.socket.addEventListener("message", handler);
|
||||
this._pendingTrackers.set(promptId, (err) => finish(false, err));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -240,7 +274,13 @@ export const comfyUI = {
|
||||
});
|
||||
const res = await fetch(`${this.baseUrl}/view?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch image: HTTP ${res.status}`);
|
||||
return await res.blob();
|
||||
const blob = await res.blob();
|
||||
// /view はSVGをセキュリティ上application/octet-streamで返すため、
|
||||
// <img>で表示できるよう拡張子から正しいMIMEタイプへ付け替える
|
||||
if (imageData.filename?.toLowerCase().endsWith(".svg") && blob.type !== "image/svg+xml") {
|
||||
return blob.slice(0, blob.size, "image/svg+xml");
|
||||
}
|
||||
return blob;
|
||||
},
|
||||
|
||||
async uploadImage(file, filename, overwrite = true) {
|
||||
@@ -270,7 +310,7 @@ export const comfyUI = {
|
||||
|
||||
// High-level generate
|
||||
async generate(workflow, options = {}) {
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError } = options;
|
||||
const { seedMode = "random", seedValue = -1, onProgress, onComplete, onError, timeoutMs } = options;
|
||||
|
||||
this.generating = true;
|
||||
try {
|
||||
@@ -292,7 +332,7 @@ export const comfyUI = {
|
||||
this.currentPromptId = result.prompt_id;
|
||||
|
||||
// Track progress
|
||||
await this.trackProgress(result.prompt_id, onProgress);
|
||||
await this.trackProgress(result.prompt_id, onProgress, timeoutMs);
|
||||
|
||||
// Get history/results
|
||||
const history = await this.getHistory(result.prompt_id);
|
||||
@@ -300,6 +340,9 @@ export const comfyUI = {
|
||||
|
||||
// Extract images from SaveImage outputs only
|
||||
const images = [];
|
||||
// comfyui-tosvg の "Save SVG String" ノードは images ではなく
|
||||
// saved_svg(ファイル名) / path(絶対パス) という独自キーで返す
|
||||
const svgOutputs = [];
|
||||
const outputs = history.outputs || {};
|
||||
for (const nodeOutput of Object.values(outputs)) {
|
||||
if (nodeOutput.images) {
|
||||
@@ -307,10 +350,23 @@ export const comfyUI = {
|
||||
images.push(img);
|
||||
}
|
||||
}
|
||||
if (nodeOutput.saved_svg) {
|
||||
// ComfyUI の実行エンジンは ui 出力の各値をイテラブルとして連結するため、
|
||||
// ノード側が単一文字列で返すと 1 文字ずつ分解された配列になってしまう。
|
||||
// (例: "a.svg" -> ["a",".","s","v","g"]) 文字配列を検出したら結合して復元する。
|
||||
const joinIfCharArray = (v) =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === "string" && c.length === 1)
|
||||
? v.join("")
|
||||
: v;
|
||||
svgOutputs.push({
|
||||
filename: joinIfCharArray(nodeOutput.saved_svg),
|
||||
path: joinIfCharArray(nodeOutput.path) || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(images, seed);
|
||||
return { images, seed };
|
||||
return { images, seed, svgOutputs };
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
throw err;
|
||||
|
||||
+464
-86
@@ -5,6 +5,7 @@
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
// ── Latent Image preset state ─────────────────────────────
|
||||
const _LATENT_PRESET_KEY = "wfm_latent_presets";
|
||||
@@ -26,6 +27,114 @@ function _saveLatentPresets(list) {
|
||||
localStorage.setItem(_LATENT_PRESET_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
// ── I2I placeholder image (Input tab / Image sub-panel) ──────────────────
|
||||
// One shared default (color-generated or a fixed uploaded image), remembered across
|
||||
// sessions. Each LoadImage slot's own "Placeholder" button applies this same default
|
||||
// to just that slot — see renderImageTab.
|
||||
const _I2I_PLACEHOLDER_KEY = "wfm_i2i_placeholder";
|
||||
const _I2I_PLACEHOLDER_DEFAULT = { mode: "color", color: "#808080", width: 512, height: 512, imageFilename: "" };
|
||||
|
||||
function _loadI2IPlaceholderConfig() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_I2I_PLACEHOLDER_KEY);
|
||||
return raw ? { ..._I2I_PLACEHOLDER_DEFAULT, ...JSON.parse(raw) } : { ..._I2I_PLACEHOLDER_DEFAULT };
|
||||
} catch { return { ..._I2I_PLACEHOLDER_DEFAULT }; }
|
||||
}
|
||||
|
||||
function _saveI2IPlaceholderConfig(cfg) {
|
||||
localStorage.setItem(_I2I_PLACEHOLDER_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
// Renders a solid-color PNG on an offscreen canvas and returns it as a File, ready to
|
||||
// upload through comfyUI.uploadImage() the same way a picked/dropped file would be.
|
||||
function _generateColorImageFile(width, height, color) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, width || 512);
|
||||
canvas.height = Math.max(1, height || 512);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = color || "#808080";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) { reject(new Error("Failed to render placeholder image")); return; }
|
||||
resolve(new File([blob], `placeholder_${canvas.width}x${canvas.height}.png`, { type: "image/png" }));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
// Wires the placeholder config panel at the top of the Image sub-panel (mode switch,
|
||||
// color/size fields, default-image drop zone). Every change is persisted immediately via
|
||||
// _saveI2IPlaceholderConfig so it's remembered next time the panel is rendered — this
|
||||
// panel is display-only config, it doesn't touch any workflow node itself; only each
|
||||
// slot's own Placeholder button (see renderImageTab) does that.
|
||||
function _wireI2IPlaceholderConfig(scopeEl) {
|
||||
const colorModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-color");
|
||||
const imageModeRadio = scopeEl.querySelector("#wfm-i2i-ph-mode-image");
|
||||
const colorFields = scopeEl.querySelector("#wfm-i2i-ph-color-fields");
|
||||
const imageFields = scopeEl.querySelector("#wfm-i2i-ph-image-fields");
|
||||
const widthInput = scopeEl.querySelector("#wfm-i2i-ph-width");
|
||||
const heightInput = scopeEl.querySelector("#wfm-i2i-ph-height");
|
||||
const colorInput = scopeEl.querySelector("#wfm-i2i-ph-color");
|
||||
const dropZone = scopeEl.querySelector("#wfm-i2i-ph-drop");
|
||||
const fileInput = scopeEl.querySelector("#wfm-i2i-ph-file");
|
||||
const previewWrap = scopeEl.querySelector("#wfm-i2i-ph-preview-wrap");
|
||||
const previewImg = scopeEl.querySelector("#wfm-i2i-ph-preview-img");
|
||||
const statusEl = scopeEl.querySelector("#wfm-i2i-ph-status");
|
||||
if (!colorModeRadio) return; // no LoadImage slots rendered — panel doesn't exist
|
||||
|
||||
const setMode = (mode) => {
|
||||
if (colorFields) colorFields.style.display = mode === "color" ? "" : "none";
|
||||
if (imageFields) imageFields.style.display = mode === "image" ? "" : "none";
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), mode });
|
||||
};
|
||||
colorModeRadio.addEventListener("change", () => { if (colorModeRadio.checked) setMode("color"); });
|
||||
imageModeRadio?.addEventListener("change", () => { if (imageModeRadio.checked) setMode("image"); });
|
||||
|
||||
widthInput?.addEventListener("change", () => {
|
||||
const width = Math.max(1, parseInt(widthInput.value, 10) || 512);
|
||||
widthInput.value = width;
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), width });
|
||||
});
|
||||
heightInput?.addEventListener("change", () => {
|
||||
const height = Math.max(1, parseInt(heightInput.value, 10) || 512);
|
||||
heightInput.value = height;
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), height });
|
||||
});
|
||||
colorInput?.addEventListener("change", () => {
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), color: colorInput.value });
|
||||
});
|
||||
|
||||
// Default image: uploaded to ComfyUI immediately (its filename is what actually gets
|
||||
// reused later, not the raw file) so every slot's Placeholder button can apply it
|
||||
// without re-uploading.
|
||||
const applyDefaultImageFile = async (file) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
if (statusEl) statusEl.textContent = t("i2iPlaceholderUploading");
|
||||
try {
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
_saveI2IPlaceholderConfig({ ..._loadI2IPlaceholderConfig(), imageFilename: result.name });
|
||||
if (previewImg) previewImg.src = `/view?filename=${encodeURIComponent(result.name)}&type=input`;
|
||||
if (previewWrap) previewWrap.style.display = "";
|
||||
if (statusEl) { statusEl.textContent = `✓ ${result.name}`; statusEl.style.color = "var(--wfm-success)"; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = `✗ ${err.message}`; statusEl.style.color = "var(--wfm-danger)"; }
|
||||
}
|
||||
};
|
||||
fileInput?.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) applyDefaultImageFile(fileInput.files[0]);
|
||||
});
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) applyDefaultImageFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _buildPresetOptions(customPresets) {
|
||||
const customs = customPresets.map((p) =>
|
||||
`<option value="${p.w}x${p.h}">${p.w}x${p.h}</option>`
|
||||
@@ -160,7 +269,10 @@ function _syncRawJson() {
|
||||
// ComfyUIネイティブのLoadImageはアルファチャンネルから mask = 1 - alpha を抽出するため、
|
||||
// 合成後の alpha は 255 - maskGray とする。
|
||||
async function _loadImageElement(src) {
|
||||
const isBlobLike = src instanceof Blob;
|
||||
// `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();
|
||||
@@ -340,11 +452,11 @@ export const comfyEditor = {
|
||||
return found || name;
|
||||
},
|
||||
|
||||
renderAll(analysis, workflow) {
|
||||
renderAll(analysis, workflow, opts = {}) {
|
||||
this.renderPromptTab(analysis, "wfm-gen-prompt-fields");
|
||||
this.renderImageTab(analysis, "wfm-gen-image-fields");
|
||||
this.renderModelTab(analysis, "wfm-gen-model-fields");
|
||||
this.renderLoraPane(analysis, "wfm-gen-lora-fields"); // async, fires independently
|
||||
this.renderLoraPane(analysis, "wfm-gen-lora-fields", opts); // async, fires independently
|
||||
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
|
||||
_syncRawJson();
|
||||
},
|
||||
@@ -363,7 +475,7 @@ export const comfyEditor = {
|
||||
<label>Positive Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-pos-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${positiveNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
@@ -375,7 +487,7 @@ export const comfyEditor = {
|
||||
<label>Negative Prompt</label>
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<select id="wfm-prompt-neg-target" class="wfm-select" style="width:auto;flex:1;min-width:0;">
|
||||
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${negativeNodes.map((n, i) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" ${i === 0 ? "selected" : ""}>ID:${n.id} (${n.title})</option>`).join("")}
|
||||
${nodeOpts}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-revert" title="${t("revertPromptTitle")}">↺</button>
|
||||
@@ -504,10 +616,14 @@ export const comfyEditor = {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
const sections = [
|
||||
// 使用頻度の高い順: Checkpoint/VAE/Diffusion Model/Text Encoder は常時表示。
|
||||
// ControlNet/Hypernetwork は意図的に使う場面が限られるため折りたたみにする。
|
||||
const mainSections = [
|
||||
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
|
||||
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
|
||||
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
|
||||
];
|
||||
const collapsibleSections = [
|
||||
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
|
||||
{
|
||||
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
|
||||
@@ -515,45 +631,70 @@ export const comfyEditor = {
|
||||
},
|
||||
];
|
||||
|
||||
el.innerHTML = sections
|
||||
.map((s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
const renderSectionBody = (s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label>${s.label}</label>
|
||||
<input type="text" class="wfm-input wfm-model-filter" placeholder="Filter..." data-target="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
return `
|
||||
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
};
|
||||
|
||||
const mainHtml = mainSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label class="${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</label>
|
||||
${renderSectionBody(s)}
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const teHtml = '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
|
||||
const collapsibleHtml = collapsibleSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary ${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</summary>
|
||||
<div class="wfm-form-group" style="margin-bottom:0;">
|
||||
${renderSectionBody(s)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join("");
|
||||
|
||||
el.innerHTML = mainHtml + teHtml + collapsibleHtml;
|
||||
|
||||
// Filter inputs
|
||||
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const targetId = input.dataset.target;
|
||||
const targetId = input.dataset.target;
|
||||
const rebuild = () => {
|
||||
const select = document.getElementById(targetId);
|
||||
if (!select) return;
|
||||
const filter = input.value.toLowerCase();
|
||||
@@ -563,7 +704,9 @@ export const comfyEditor = {
|
||||
.filter((m) => m.toLowerCase().includes(filter))
|
||||
.map((m) => `<option value="${m}">${m}</option>`)
|
||||
.join("");
|
||||
});
|
||||
};
|
||||
input.addEventListener("input", rebuild);
|
||||
setupSearchClearBtn(input.id, `${input.id}-clear`, rebuild);
|
||||
});
|
||||
|
||||
// Apply buttons
|
||||
@@ -599,13 +742,34 @@ export const comfyEditor = {
|
||||
_initTextEncoderSection(analysis, this.models.textEncoders || [], el).catch(() => {});
|
||||
},
|
||||
|
||||
async renderLoraPane(analysis, containerId) {
|
||||
async renderLoraPane(analysis, containerId, opts = {}) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
const loraNodes = analysis.lora_nodes || [];
|
||||
document.getElementById("wfm-gen-lora-col-title")?.classList.toggle("wfm-model-label-active", loraNodes.length > 0);
|
||||
const loras = this.models.loras || [];
|
||||
const defaultStackTarget = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
|
||||
const hasLoraManager = loraNodes.some((n) => n.is_lora_manager);
|
||||
// Use Stack Group チェックボックスの現在状態を先読み(Stack表示の取得元判定に使う)。
|
||||
// resetStackMode(新規読み込み)時は常にOFF扱い。
|
||||
const stackModeChecked = hasLoraManager && !opts.resetStackMode && (el.querySelector("#wfm-lora-stack-mode-toggle")?.checked ?? false);
|
||||
|
||||
// ワークフロー新規読み込み時のみ: LoraManagerノードのloras/text状態をスナップショットし、
|
||||
// Stackグループ使用チェックボックスをOFFへリセットする(既存のGenUI状態への影響を避けるため
|
||||
// タブ切替やStack再読込などの単なる再描画では行わない)。
|
||||
if (opts.resetStackMode) {
|
||||
comfyUI.loraManagerSnapshots = {};
|
||||
for (const n of loraNodes) {
|
||||
if (!n.is_lora_manager) continue;
|
||||
const wfNode = comfyUI.currentWorkflow?.[n.id];
|
||||
if (!wfNode?.inputs) continue;
|
||||
comfyUI.loraManagerSnapshots[n.id] = {
|
||||
loras: Array.isArray(wfNode.inputs.loras?.__value__) ? JSON.parse(JSON.stringify(wfNode.inputs.loras.__value__)) : [],
|
||||
text: wfNode.inputs.text ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
const nodeOpts = loraNodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
@@ -630,26 +794,53 @@ export const comfyEditor = {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Stack group, metadata, CivitAI cache in parallel
|
||||
// Stack表示の取得元: ON(Use Stack Group)ならModelsタブの登録済みStackグループ、
|
||||
// OFF(既定)ならLoraManagerノードに実際に設定されているLoRAをそのまま表示する。
|
||||
let stackModels = [];
|
||||
let metadata = {};
|
||||
let civitaiCache = {};
|
||||
const useWorkflowLoraState = hasLoraManager && !stackModeChecked;
|
||||
try {
|
||||
const [grpRes, metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/groups?type=lora"),
|
||||
const [metaRes, civRes] = await Promise.all([
|
||||
fetch("/api/wfm/models/metadata"),
|
||||
fetch("/api/wfm/models/civitai/cache"),
|
||||
]);
|
||||
const groups = grpRes.ok ? await grpRes.json() : {};
|
||||
stackModels = groups["Stack"] || [];
|
||||
metadata = metaRes.ok ? await metaRes.json() : {};
|
||||
civitaiCache = civRes.ok ? await civRes.json() : {};
|
||||
} catch { /* ignore */ }
|
||||
|
||||
stackModels.forEach((m) => {
|
||||
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
|
||||
if (_stackActive[m] === undefined) _stackActive[m] = true;
|
||||
});
|
||||
if (stackModeChecked) {
|
||||
try {
|
||||
const grpRes = await fetch("/api/wfm/models/groups?type=lora");
|
||||
const groups = grpRes.ok ? await grpRes.json() : {};
|
||||
stackModels = groups["Stack"] || [];
|
||||
} catch { /* ignore */ }
|
||||
stackModels.forEach((m) => {
|
||||
if (!_stackStrengths[m]) _stackStrengths[m] = { m: 1.0, c: 1.0 };
|
||||
if (_stackActive[m] === undefined) _stackActive[m] = true;
|
||||
});
|
||||
} else if (useWorkflowLoraState) {
|
||||
const wfNode = comfyUI.currentWorkflow?.[defaultStackTarget];
|
||||
const wfLoras = wfNode?.inputs?.loras?.__value__;
|
||||
// resetStackMode(新規読み込み)/syncFromWorkflow(チェックボックスOFF切替直後)の時だけ
|
||||
// ワークフローの実データで強制上書きする。タブ切替等の単なる再描画では、
|
||||
// 既にメモリ上に値があればそれを維持し、Stackパネルでの一時的なON/OFF・強度編集を
|
||||
// 再描画のたびに消してしまわないようにする。
|
||||
const shouldSync = opts.resetStackMode || opts.syncFromWorkflow;
|
||||
if (Array.isArray(wfLoras)) {
|
||||
stackModels = wfLoras.map((l) => {
|
||||
const found = (this.models.loras || []).find((m) => _loraBasename(m) === l.name);
|
||||
const full = found || l.name;
|
||||
if (shouldSync || _stackStrengths[full] === undefined) {
|
||||
_stackStrengths[full] = { m: parseFloat(l.strength) || 1.0, c: parseFloat(l.clipStrength ?? l.strength) || 1.0 };
|
||||
}
|
||||
if (shouldSync || _stackActive[full] === undefined) {
|
||||
_stackActive[full] = l.active !== false;
|
||||
}
|
||||
return full;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build Stack trigger words
|
||||
const activeTriggerWords = [];
|
||||
@@ -666,7 +857,9 @@ export const comfyEditor = {
|
||||
? activeTriggerWords.map((w) => `<span class="wfm-lora-trigger-word">${w}</span>`).join(" ")
|
||||
: `<span style="color:var(--wfm-text-secondary);font-size:12px;">—</span>`;
|
||||
const stackLoraSyntax = _buildLoraSyntax(stackModels);
|
||||
const allActive = stackModels.every((m) => _stackActive[m] !== false);
|
||||
// stackModelsが空だと every() が空配列に対し true を返す(空虚な真)ため、
|
||||
// 何も無いのに「トグルオール」チェックボックスがON表示になるのを防ぐ
|
||||
const allActive = stackModels.length > 0 && stackModels.every((m) => _stackActive[m] !== false);
|
||||
const anyActive = stackModels.some((m) => _stackActive[m] !== false);
|
||||
|
||||
const stackModelRows = stackModels.map((m) => {
|
||||
@@ -684,7 +877,10 @@ export const comfyEditor = {
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
|
||||
let _prevActiveTab = el.querySelector(".wfm-lora-tab-btn.active")?.dataset?.tab || "single";
|
||||
// 新規読み込み時、LoraManagerノードに実際に複数LoRAが設定されている場合は
|
||||
// Single(1件しか表示できない)ではなくStackタブを初期表示にする
|
||||
if (opts.resetStackMode && useWorkflowLoraState && stackModels.length > 1) _prevActiveTab = "stack";
|
||||
// applyToGenUIで設定したSingle表示の状態を保存(再描画後に復元)
|
||||
const _prevSingleSyntax = document.getElementById("wfm-lora-single-syntax")?.textContent || "";
|
||||
const _prevSingleTriggers = document.getElementById("wfm-lora-single-triggers")?.innerHTML || "";
|
||||
@@ -693,11 +889,19 @@ export const comfyEditor = {
|
||||
<div class="wfm-lora-tab-header">
|
||||
<button class="wfm-lora-tab-btn active" data-tab="single">Single</button>
|
||||
<button class="wfm-lora-tab-btn" data-tab="stack">Stack</button>
|
||||
${hasLoraManager ? `
|
||||
<label class="wfm-lora-stack-mode-label" style="margin-left:auto;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;" title="ON: 登録済みStackグループを読み込む / OFF: ワークフローに実際に設定されているLoRAを表示・編集する">
|
||||
<input type="checkbox" id="wfm-lora-stack-mode-toggle" ${stackModeChecked ? "checked" : ""}>
|
||||
Use Stack Group
|
||||
</label>` : ""}
|
||||
</div>
|
||||
|
||||
<!-- Single tab -->
|
||||
<div class="wfm-lora-tab-content" id="wfm-lora-panel-single">
|
||||
<input type="text" class="wfm-input" id="wfm-lora-filter" placeholder="Filter...">
|
||||
<div class="wfm-search-wrap" style="width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input" id="wfm-lora-filter" placeholder="Filter...">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-lora-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select" id="wfm-lora-select">
|
||||
${loras.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
@@ -724,6 +928,7 @@ export const comfyEditor = {
|
||||
|
||||
<!-- Stack tab -->
|
||||
<div class="wfm-lora-tab-content" id="wfm-lora-panel-stack" style="display:none;">
|
||||
${hasLoraManager ? `<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:6px;">${useWorkflowLoraState ? "Showing: LoRAs currently in workflow" : "Showing: registered Stack group"}</div>` : ""}
|
||||
<div class="wfm-lora-stack-header">
|
||||
<select class="wfm-select" id="wfm-lora-stack-target" style="flex:1;min-width:0;">${stackTargetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-lora-stack-apply" title="Apply Stack to node and sync to Positive prompt">Apply</button>
|
||||
@@ -758,7 +963,7 @@ export const comfyEditor = {
|
||||
<div id="wfm-lora-stack-triggers" class="wfm-lora-stack-triggers">${stackTriggerHtml}</div>
|
||||
</div>
|
||||
<div class="wfm-lora-stack-models">
|
||||
${stackModelRows || `<p class="wfm-placeholder">No models in Stack group</p>`}
|
||||
${stackModelRows || `<p class="wfm-placeholder">${useWorkflowLoraState ? "No LoRAs set on this node" : "No models in Stack group"}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -796,9 +1001,63 @@ export const comfyEditor = {
|
||||
document.getElementById("wfm-lora-panel-stack").style.display = "";
|
||||
}
|
||||
|
||||
// ── Stackグループ使用チェックボックス ─────────────────
|
||||
// ON: LoraManagerノードへStackグループを(クリアしてから)読み込む(既存のStack Applyを流用)
|
||||
// OFF: ワークフロー読み込み時点のloras/text状態へ(クリアしてから)戻す
|
||||
document.getElementById("wfm-lora-stack-mode-toggle")?.addEventListener("change", async (e) => {
|
||||
const nodeId = document.getElementById("wfm-lora-stack-target")?.value || defaultStackTarget;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
|
||||
if (e.target.checked) {
|
||||
// stackModelsはこの時点では「ワークフロー state」を指したままのため、
|
||||
// 先に再描画してStackグループを取得させてからApplyする
|
||||
await this.renderLoraPane(analysis, containerId);
|
||||
document.getElementById("wfm-lora-stack-apply")?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const snap = comfyUI.loraManagerSnapshots?.[nodeId] || { loras: [], text: "" };
|
||||
comfyUI.currentWorkflow[nodeId].inputs.loras = { __value__: JSON.parse(JSON.stringify(snap.loras)) };
|
||||
comfyUI.currentWorkflow[nodeId].inputs.text = snap.text;
|
||||
_syncRawJson();
|
||||
|
||||
// ON時にPositiveプロンプトへ挿入されたStack由来の<lora:...>構文とトリガーワードを除去する
|
||||
// (新規に追加はしない。ワークフロー本来のプロンプトへ戻すだけ)
|
||||
const posTextarea = document.getElementById("wfm-prompt-pos-text");
|
||||
if (posTextarea && stackModels.length > 0) {
|
||||
let cleaned = posTextarea.value;
|
||||
for (const m of stackModels) {
|
||||
const stem = _loraBasename(m).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
cleaned = cleaned.replace(new RegExp(`,?\\s*<lora:${stem}:[^>]*>`, "gi"), "");
|
||||
}
|
||||
const stackWords = new Set();
|
||||
stackModels.forEach((m) => {
|
||||
const sha = (metadata[m] || {}).sha256;
|
||||
const civInfo = sha && civitaiCache[sha];
|
||||
civInfo?.trainedWords?.forEach((w) => stackWords.add(w.trim().toLowerCase()));
|
||||
});
|
||||
if (stackWords.size > 0) {
|
||||
cleaned = cleaned.split(",").map((p) => p.trim()).filter((p) => p && !stackWords.has(p.toLowerCase())).join(", ");
|
||||
}
|
||||
posTextarea.value = cleaned.replace(/,\s*$/, "").trim();
|
||||
|
||||
const posNodeId = document.getElementById("wfm-prompt-pos-target")?.value;
|
||||
if (posNodeId && comfyUI.currentWorkflow?.[posNodeId]) {
|
||||
const promptNode = comfyUI.currentAnalysis?.prompt_nodes?.find((n) => String(n.id) === String(posNodeId));
|
||||
comfyUI.currentWorkflow[posNodeId].inputs[promptNode?.textKey || "text"] = posTextarea.value;
|
||||
_syncRawJson();
|
||||
}
|
||||
}
|
||||
|
||||
// syncFromWorkflow: OFFへ戻した直後は、たった今書き戻したloras/text状態と
|
||||
// Stackパネルの強度/有効状態表示を一致させるため強制同期する
|
||||
this.renderLoraPane(analysis, containerId, { syncFromWorkflow: true });
|
||||
});
|
||||
|
||||
// ── Single: filter ───────────────────────────────────
|
||||
document.getElementById("wfm-lora-filter")?.addEventListener("input", (e) => {
|
||||
const filter = e.target.value.toLowerCase();
|
||||
const loraFilterInput = document.getElementById("wfm-lora-filter");
|
||||
const rebuildLoraSingleList = () => {
|
||||
const filter = loraFilterInput?.value.toLowerCase() || "";
|
||||
const select = document.getElementById("wfm-lora-select");
|
||||
if (!select) return;
|
||||
select.innerHTML = loras
|
||||
@@ -806,7 +1065,9 @@ export const comfyEditor = {
|
||||
.map((m) => `<option value="${m}">${m}</option>`)
|
||||
.join("");
|
||||
_refreshLoraSingleDynamic(metadata, civitaiCache);
|
||||
});
|
||||
};
|
||||
loraFilterInput?.addEventListener("input", rebuildLoraSingleList);
|
||||
setupSearchClearBtn("wfm-lora-filter", "wfm-lora-filter-clear", rebuildLoraSingleList);
|
||||
|
||||
// ── Single: update SYNTAX/TRIGGERS on model or strength change ──
|
||||
document.getElementById("wfm-lora-select")?.addEventListener("change", () => _refreshLoraSingleDynamic(metadata, civitaiCache));
|
||||
@@ -1023,23 +1284,39 @@ export const comfyEditor = {
|
||||
const sampler = analysis.sampler_nodes?.[0];
|
||||
const latent = analysis.latent_nodes?.[0];
|
||||
|
||||
// "Advanced Sampling" workflows (Flux.1/Flux.2, SD3.5, Chroma...) spread seed/steps/cfg/
|
||||
// sampler/scheduler/denoise across RandomNoise + CFGGuider + KSamplerSelect + a *Scheduler
|
||||
// node instead of one KSampler. sampler.*NodeId (set in analyzeWorkflow) says which real
|
||||
// node each field writes back to; null/"" means that workflow has no such control at all
|
||||
// (e.g. Flux2Scheduler has no named "scheduler" combo) so the field is disabled here.
|
||||
const schedulerAvailable = !!sampler && sampler.schedulerNodeId != null;
|
||||
const denoiseAvailable = !!sampler && sampler.denoiseNodeId != null;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:row;gap:0;align-items:flex-start;">
|
||||
<div style="flex:1;min-width:0;padding-right:14px;border-right:1px solid var(--wfm-border);">
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">${sampler?.advanced ? "Sampler (Advanced)" : "KSampler"}</h3>
|
||||
${sampler ? `
|
||||
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
|
||||
<input type="hidden" id="wfm-settings-sampler-id"
|
||||
data-seed-key="${sampler.seedKey || "seed"}"
|
||||
data-seed-node-id="${sampler.seedNodeId ?? sampler.id}"
|
||||
data-steps-node-id="${sampler.stepsNodeId ?? sampler.id}"
|
||||
data-cfg-key="${sampler.cfgKey || "cfg"}"
|
||||
data-cfg-node-id="${sampler.cfgNodeId ?? sampler.id}"
|
||||
data-sampler-node-id="${sampler.samplerNodeId ?? sampler.id}"
|
||||
data-scheduler-node-id="${sampler.schedulerNodeId ?? ""}"
|
||||
data-denoise-node-id="${sampler.denoiseNodeId ?? ""}">
|
||||
<div class="wfm-form-group">
|
||||
<label>Seed</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? ""}" placeholder="${sampler.seed === undefined ? "linked" : ""}">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Steps</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? 20}" min="1" max="200">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-steps" value="${sampler.steps ?? ""}" placeholder="${sampler.steps === undefined ? "linked" : ""}" min="1" max="200">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>CFG</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? 7}" step="0.5" min="0">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-cfg" value="${sampler.cfg ?? ""}" placeholder="${sampler.cfg === undefined ? "linked" : ""}" step="0.5" min="0">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Sampler</label>
|
||||
@@ -1049,13 +1326,15 @@ export const comfyEditor = {
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Scheduler</label>
|
||||
<select class="wfm-select" id="wfm-settings-scheduler">
|
||||
${this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")}
|
||||
<select class="wfm-select" id="wfm-settings-scheduler" ${schedulerAvailable ? "" : "disabled"}>
|
||||
${schedulerAvailable
|
||||
? this.models.schedulers.map((s) => `<option value="${s}" ${s === sampler.scheduler ? "selected" : ""}>${s}</option>`).join("")
|
||||
: `<option value="">N/A</option>`}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Denoise</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? 1.0}" step="0.05" min="0" max="1">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-denoise" value="${sampler.denoise ?? ""}" placeholder="${denoiseAvailable && sampler.denoise === undefined ? "linked" : ""}" step="0.05" min="0" max="1" ${denoiseAvailable ? "" : "disabled"}>
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
|
||||
@@ -1066,17 +1345,18 @@ export const comfyEditor = {
|
||||
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
|
||||
<div class="wfm-form-group">
|
||||
<label>Width</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? 512}" step="8" min="64">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-width" value="${latent.width ?? ""}" placeholder="${latent.width === undefined ? "linked" : ""}" step="8" min="64">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Height</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? 512}" step="8" min="64">
|
||||
<input type="number" class="wfm-input" id="wfm-settings-height" value="${latent.height ?? ""}" placeholder="${latent.height === undefined ? "linked" : ""}" step="8" min="64">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Batch Size</label>
|
||||
<input type="number" class="wfm-input" id="wfm-settings-batch" value="${latent.batch_size ?? 1}" min="1" max="64">
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-latent-apply">Apply</button>
|
||||
${latent.width === undefined || latent.height === undefined ? `<p class="wfm-placeholder" style="margin-top:6px;">Width/Height is linked to another node (e.g. auto-follows the input image) — leave blank to keep it linked.</p>` : ""}
|
||||
<div style="margin-top:10px;border-top:1px solid var(--wfm-border);padding-top:10px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
|
||||
${[512, 768, 1024, 2048].map((s) => `<button class="wfm-btn wfm-btn-sm wfm-latent-sq-btn" data-size="${s}">${s}</button>`).join("")}
|
||||
@@ -1102,17 +1382,21 @@ export const comfyEditor = {
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
|
||||
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
// KSamplerAdvanced uses noise_seed; fall back to seed
|
||||
const seedKey = document.getElementById("wfm-settings-sampler-id")?.dataset?.seedKey || "seed";
|
||||
inputs[seedKey] = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
|
||||
inputs.steps = parseInt(document.getElementById("wfm-settings-steps")?.value) || 20;
|
||||
inputs.cfg = parseFloat(document.getElementById("wfm-settings-cfg")?.value) || 7;
|
||||
inputs.sampler_name = document.getElementById("wfm-settings-sampler-name")?.value;
|
||||
inputs.scheduler = document.getElementById("wfm-settings-scheduler")?.value;
|
||||
inputs.denoise = parseFloat(document.getElementById("wfm-settings-denoise")?.value) || 1.0;
|
||||
const ds = document.getElementById("wfm-settings-sampler-id")?.dataset || {};
|
||||
const write = (nodeId, key, value) => {
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
comfyUI.currentWorkflow[nodeId].inputs[key] = value;
|
||||
};
|
||||
const seedEl = document.getElementById("wfm-settings-seed");
|
||||
if (seedEl?.value !== "") write(ds.seedNodeId, ds.seedKey || "seed", parseInt(seedEl.value) || -1);
|
||||
const stepsEl = document.getElementById("wfm-settings-steps");
|
||||
if (stepsEl?.value !== "") write(ds.stepsNodeId, "steps", parseInt(stepsEl.value) || 20);
|
||||
const cfgEl = document.getElementById("wfm-settings-cfg");
|
||||
if (cfgEl?.value !== "") write(ds.cfgNodeId, ds.cfgKey || "cfg", parseFloat(cfgEl.value) || 7);
|
||||
write(ds.samplerNodeId, "sampler_name", document.getElementById("wfm-settings-sampler-name")?.value);
|
||||
if (ds.schedulerNodeId) write(ds.schedulerNodeId, "scheduler", document.getElementById("wfm-settings-scheduler")?.value);
|
||||
const denoiseEl = document.getElementById("wfm-settings-denoise");
|
||||
if (ds.denoiseNodeId && denoiseEl?.value !== "") write(ds.denoiseNodeId, "denoise", parseFloat(denoiseEl.value) || 1.0);
|
||||
_syncRawJson();
|
||||
if (e.altKey) document.dispatchEvent(new CustomEvent("wfm:apply-and-generate"));
|
||||
});
|
||||
@@ -1121,8 +1405,11 @@ export const comfyEditor = {
|
||||
const nodeId = document.getElementById("wfm-settings-latent-id")?.value;
|
||||
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
|
||||
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
|
||||
inputs.width = parseInt(document.getElementById("wfm-settings-width")?.value) || 512;
|
||||
inputs.height = parseInt(document.getElementById("wfm-settings-height")?.value) || 512;
|
||||
const wEl = document.getElementById("wfm-settings-width");
|
||||
const hEl = document.getElementById("wfm-settings-height");
|
||||
// Blank = leave the (linked) width/height alone instead of clobbering it with a default
|
||||
if (wEl?.value !== "") inputs.width = parseInt(wEl.value) || 512;
|
||||
if (hEl?.value !== "") inputs.height = parseInt(hEl.value) || 512;
|
||||
inputs.batch_size = parseInt(document.getElementById("wfm-settings-batch")?.value) || 1;
|
||||
_syncRawJson();
|
||||
});
|
||||
@@ -1205,8 +1492,34 @@ export const comfyEditor = {
|
||||
const slots = loadNodes.slice(0, 4);
|
||||
// Mask Editor One ノード(画像+マスクを内包、常に両方必須)— 別枠で最大2件表示
|
||||
const meoSlots = meoNodes.slice(0, 2);
|
||||
const phCfg = _loadI2IPlaceholderConfig();
|
||||
const isColorMode = phCfg.mode !== "image";
|
||||
el.innerHTML = `
|
||||
${slots.length === 0 ? "" : `
|
||||
<details class="wfm-settings-section wfm-i2i-placeholder-config">
|
||||
<summary class="wfm-settings-summary">${t("i2iPlaceholderTitle")}</summary>
|
||||
<div class="wfm-i2i-placeholder-mode">
|
||||
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-color" value="color" ${isColorMode ? "checked" : ""}> ${t("i2iPlaceholderColor")}</label>
|
||||
<label><input type="radio" name="wfm-i2i-ph-mode" id="wfm-i2i-ph-mode-image" value="image" ${isColorMode ? "" : "checked"}> ${t("i2iPlaceholderImage")}</label>
|
||||
</div>
|
||||
<div id="wfm-i2i-ph-color-fields" class="wfm-i2i-placeholder-fields wfm-lab-modal-inline" style="${isColorMode ? "" : "display:none;"}">
|
||||
<div><label>${t("i2iPlaceholderWidth")}</label><input type="number" min="1" id="wfm-i2i-ph-width" class="wfm-input" value="${Number(phCfg.width) || 512}"></div>
|
||||
<div><label>${t("i2iPlaceholderHeight")}</label><input type="number" min="1" id="wfm-i2i-ph-height" class="wfm-input" value="${Number(phCfg.height) || 512}"></div>
|
||||
<div><label>${t("i2iPlaceholderColorLabel")}</label><input type="color" id="wfm-i2i-ph-color" class="wfm-input" value="${escapeHtml(/^#[0-9a-fA-F]{6}$/.test(phCfg.color) ? phCfg.color : "#808080")}"></div>
|
||||
</div>
|
||||
<div id="wfm-i2i-ph-image-fields" class="wfm-i2i-placeholder-fields" style="${isColorMode ? "display:none;" : ""}">
|
||||
<div class="wfm-i2i-preview-wrap" id="wfm-i2i-ph-preview-wrap" style="${phCfg.imageFilename ? "" : "display:none;"}">
|
||||
<img class="wfm-i2i-preview-img" id="wfm-i2i-ph-preview-img" src="${phCfg.imageFilename ? `/view?filename=${encodeURIComponent(phCfg.imageFilename)}&type=input` : ""}">
|
||||
</div>
|
||||
<div class="wfm-i2i-drop-zone" id="wfm-i2i-ph-drop">
|
||||
<label class="wfm-i2i-drop-label">
|
||||
${t("i2iPlaceholderDropImage")}
|
||||
<input type="file" accept="image/*" id="wfm-i2i-ph-file" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-ph-status"></span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="wfm-i2i-grid">
|
||||
${slots.map((node, i) => `
|
||||
<div class="wfm-i2i-slot" data-slot="${i}" data-node-id="${node.id}">
|
||||
@@ -1240,8 +1553,10 @@ export const comfyEditor = {
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary wfm-i2i-apply" data-slot="${i}" disabled>Apply</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-placeholder-btn" data-slot="${i}">${t("i2iPlaceholderApply")}</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm wfm-i2i-clear" data-slot="${i}">${t("clear")}</button>
|
||||
<span class="wfm-i2i-status" id="wfm-i2i-status-${i}"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1402,8 +1717,64 @@ export const comfyEditor = {
|
||||
applyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear: reset this slot's staged/preview state back to empty. Doesn't touch
|
||||
// the workflow node itself — LoadImage always needs *some* valid filename, so
|
||||
// there's nothing meaningful to "unset" there; this just lets the user cancel
|
||||
// a pending pick and start over.
|
||||
const clearBtn = el.querySelector(`.wfm-i2i-clear[data-slot="${i}"]`);
|
||||
clearBtn?.addEventListener("click", () => {
|
||||
pendingFile = null;
|
||||
previewImg.src = "";
|
||||
previewWrap.style.display = "none";
|
||||
filenameEl.textContent = "";
|
||||
if (fileInput) fileInput.value = "";
|
||||
applyBtn.disabled = !pendingMaskFile;
|
||||
statusEl.textContent = "";
|
||||
});
|
||||
|
||||
// Placeholder: generate (color mode) or reuse (image mode) the shared default
|
||||
// configured at the top of the panel, and replace this slot's image
|
||||
// immediately — no separate Apply click needed, matching the "one button,
|
||||
// one slot" behavior described for this feature.
|
||||
const placeholderBtn = el.querySelector(`.wfm-i2i-placeholder-btn[data-slot="${i}"]`);
|
||||
placeholderBtn?.addEventListener("click", async () => {
|
||||
const cfg = _loadI2IPlaceholderConfig();
|
||||
placeholderBtn.disabled = true;
|
||||
statusEl.textContent = t("i2iPlaceholderGenerating");
|
||||
try {
|
||||
let filename;
|
||||
if (cfg.mode === "image") {
|
||||
if (!cfg.imageFilename) throw new Error(t("i2iPlaceholderNoDefaultImage"));
|
||||
filename = cfg.imageFilename;
|
||||
} else {
|
||||
const file = await _generateColorImageFile(cfg.width, cfg.height, cfg.color);
|
||||
const result = await comfyUI.uploadImage(file, file.name);
|
||||
if (!result.name) throw new Error("Upload returned no filename");
|
||||
filename = result.name;
|
||||
}
|
||||
if (comfyUI.currentWorkflow?.[node.id]) {
|
||||
comfyUI.currentWorkflow[node.id].inputs.image = filename;
|
||||
}
|
||||
pendingFile = null;
|
||||
previewImg.src = `/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
previewWrap.style.display = "";
|
||||
filenameEl.textContent = filename;
|
||||
applyBtn.disabled = !pendingMaskFile;
|
||||
statusEl.textContent = `✓ ${filename}`;
|
||||
statusEl.style.color = "var(--wfm-success)";
|
||||
_syncRawJson();
|
||||
} catch (err) {
|
||||
statusEl.textContent = `✗ ${err.message}`;
|
||||
statusEl.style.color = "var(--wfm-danger)";
|
||||
} finally {
|
||||
placeholderBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_wireI2IPlaceholderConfig(el);
|
||||
|
||||
// Initialize each Mask Editor One slot(画像+マスク両方が揃って初めてApply可能)
|
||||
meoSlots.forEach((node, i) => {
|
||||
const fileInput = document.getElementById(`wfm-meo-file-${i}`);
|
||||
@@ -1475,18 +1846,25 @@ export const comfyEditor = {
|
||||
});
|
||||
},
|
||||
|
||||
async applyImageToSlot(file, slotIndex = 0) {
|
||||
const loadNodes = comfyUI.currentAnalysis?.load_image_nodes || [];
|
||||
// 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 (comfyUI.currentWorkflow?.[node.id]) {
|
||||
comfyUI.currentWorkflow[node.id].inputs.image = result.name;
|
||||
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}`);
|
||||
@@ -1738,7 +2116,7 @@ async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
|
||||
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
|
||||
|
||||
section.innerHTML = `
|
||||
<label>Text Encoder</label>
|
||||
<label class="wfm-model-label-active">Text Encoder</label>
|
||||
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
|
||||
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
|
||||
|
||||
+646
-32
@@ -5,6 +5,61 @@
|
||||
// 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 AND Reroute nodes, matching each
|
||||
* one's output slot to an input slot of the same type and following the connection upstream
|
||||
* (recursively, for chains of either/both). Mirrors ComfyUI's own Bypass behavior, where a
|
||||
* bypassed node is removed but its wires are reconnected end-to-end — Reroute needs the identical
|
||||
* treatment because it's a UI-only passthrough node with no backend node class at all: leaving it
|
||||
* in the API payload makes ComfyUI reject the whole prompt with "missing_node_type" (Reroute
|
||||
* always has to be resolved through, regardless of its mode).
|
||||
* 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 && node.type !== "Reroute")) return [idStr, slot];
|
||||
if (visited.has(idStr)) return null; // circular bypass/reroute chain guard
|
||||
|
||||
visited.add(idStr);
|
||||
const outputs = node.outputs || [];
|
||||
const inputs = node.inputs || [];
|
||||
|
||||
let inIdx;
|
||||
if (node.type === "Reroute") {
|
||||
// Reroute is always exactly one input -> one output. Its stored input type is commonly
|
||||
// the generic "*" even when the output shows the propagated concrete type (e.g. MODEL),
|
||||
// so the type-match search used for Bypass below would fail to find it — just take the
|
||||
// sole input.
|
||||
inIdx = 0;
|
||||
} else {
|
||||
const outType = outputs[slot]?.type;
|
||||
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 || inIdx === undefined) 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 {
|
||||
@@ -30,6 +85,9 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// Required inputs: skip types that are linked (have uppercase first letter = node output types)
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
// forceInput: true — link-only socket even though its type is a widget type
|
||||
// (e.g. CLIPTextEncodeEditPlus's text1/text2). Never has a widgets_values entry.
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
// Link types are strings like "MODEL", "CLIP", "CONDITIONING", "LATENT", "IMAGE", etc.
|
||||
// Widget types are: "INT", "FLOAT", "STRING", "BOOLEAN", or an array of choices
|
||||
if (Array.isArray(type)) {
|
||||
@@ -37,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" || 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
|
||||
@@ -47,11 +105,12 @@ function _getWidgetInputNames(objectInfo, classType) {
|
||||
// Optional inputs that are widgets
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
names.push(name);
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +121,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];
|
||||
@@ -73,22 +132,24 @@ function _getWidgetInputTypes(objectInfo, classType) {
|
||||
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const type = Array.isArray(spec) ? spec[0] : spec;
|
||||
if (Array.isArray(spec) && spec[1]?.forceInput) continue;
|
||||
if (Array.isArray(type)) {
|
||||
types.push("COMBO");
|
||||
} else if (typeof type === "string") {
|
||||
const upper = type.toUpperCase();
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO") {
|
||||
if (upper === "INT" || upper === "FLOAT" || upper === "STRING" || upper === "BOOLEAN" || upper === "COMBO" || upper === "COMFY_DYNAMICCOMBO_V3") {
|
||||
types.push(upper);
|
||||
}
|
||||
}
|
||||
@@ -96,6 +157,29 @@ 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: {...} } }, ...] }]
|
||||
*
|
||||
* Includes both required AND optional sub-fields: ComfyUI serializes every sub-field of the
|
||||
* selected option into widgets_values regardless of which bucket it's declared in (e.g.
|
||||
* TextGenerate's "on" option puts temperature/top_k/.../seed under required but
|
||||
* presence_penalty under optional — all 7 still occupy a widgets_values slot). Dropping the
|
||||
* optional ones here under-counts how many slots the combo actually consumed, misaligning
|
||||
* every widget declared after it in the node's schema.
|
||||
*/
|
||||
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 || {}),
|
||||
...Object.keys(opt?.inputs?.optional || {}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -113,6 +197,140 @@ function _isExtraWidgetValue(val, expectedType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a subgraph-template node's widget input (by name) is actually wired end-to-end —
|
||||
* either redirected from an outer parent-graph link, or wired to another internal node within the
|
||||
* same subgraph template (boundary links from the subgraph's own input ports, origin_id === -10,
|
||||
* don't count: those resolve to a plain injected widget value instead, handled separately).
|
||||
*/
|
||||
function _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name) {
|
||||
const inputDef = (remappedNode.inputs || []).find((i) => i.widget && i.name === name);
|
||||
if (!inputDef) return false;
|
||||
const slotIdx = remappedNode.inputs.indexOf(inputDef);
|
||||
if (redirectedTargets.has(`${remappedNode.id}:${slotIdx}`)) return true;
|
||||
return inputDef.link != null && (sgDef.links || []).some(
|
||||
(l) => l.id === inputDef.link && String(l.target_id) === origInternalId && l.target_slot === slotIdx && l.origin_id !== -10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate consuming widgets_values against object_info's widget name/type order, accounting for
|
||||
* frontend-only "control_after_generate" extras (e.g. "fixed" after a seed) that occupy one extra
|
||||
* slot and shift every later index by one. When skipLinked is true, widgets the caller's
|
||||
* isLinkedName() flags as linked are treated as consuming zero widgets_values entries (the
|
||||
* "modern" ComfyUI format); with it false, every widget is assumed present (the "legacy full"
|
||||
* format some subgraph templates store). Returns each widget's actual widgets_values index
|
||||
* (or -1 if the array ran out), plus consumedIndices — every index a widget's own turn advanced
|
||||
* past (normally just its value, but two when a control_after_generate extra was skipped) — and
|
||||
* how many entries were consumed in total (the latter is how callers detect which of the two
|
||||
* formats a given widgets_values array actually follows).
|
||||
*
|
||||
* consumedIndices matters because convertUiToApi's own widget-mapping loop only skips a
|
||||
* control_after_generate extra while advancing past the *next* widget's turn (see its
|
||||
* `!linkedSlotNames.has(name)` branch) — if that next widget turns out to be linked, the loop
|
||||
* `continue`s before ever looking at the extra, leaving it unconsumed and silently misread as the
|
||||
* value of whatever non-linked widget comes after. Stripping a linked widget's position alone
|
||||
* (without its swallowed extra) reproduces exactly that bug one level up, in the injection step.
|
||||
*
|
||||
* comboExpander(name, selectedKey) — when a widget's type is COMFY_DYNAMICCOMBO_V3, its own turn
|
||||
* only consumes the *selector* slot (the chosen option's key); the selected option's sub-fields
|
||||
* (e.g. TextGenerate's sampling_mode → temperature/top_k/.../seed/presence_penalty) each occupy
|
||||
* one more widgets_values slot right after it. Without expanding those here too, every widget
|
||||
* declared later in the node's schema (e.g. TextGenerate's "thinking") gets matched against a
|
||||
* slot that's actually still inside the combo's sub-field span, corrupting both values.
|
||||
*/
|
||||
function _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, skipLinked, comboExpander) {
|
||||
let idx = 0;
|
||||
const positions = {};
|
||||
const consumedIndices = {};
|
||||
for (let nIdx = 0; nIdx < widgetNames.length; nIdx++) {
|
||||
const name = widgetNames[nIdx];
|
||||
if (skipLinked && isLinkedName(name)) {
|
||||
positions[name] = -1;
|
||||
consumedIndices[name] = [];
|
||||
continue;
|
||||
}
|
||||
if (idx >= widgetsValues.length) {
|
||||
positions[name] = -1;
|
||||
consumedIndices[name] = [];
|
||||
continue;
|
||||
}
|
||||
const consumed = [idx];
|
||||
let p = idx;
|
||||
idx++;
|
||||
if (_isExtraWidgetValue(widgetsValues[p], widgetTypes[nIdx]) && idx < widgetsValues.length) {
|
||||
consumed.push(idx);
|
||||
p = idx;
|
||||
idx++;
|
||||
}
|
||||
if (widgetTypes[nIdx] === "COMFY_DYNAMICCOMBO_V3" && comboExpander) {
|
||||
const subNames = comboExpander(name, widgetsValues[p]);
|
||||
for (let s = 0; s < subNames.length && idx < widgetsValues.length; s++) {
|
||||
consumed.push(idx);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
positions[name] = p;
|
||||
consumedIndices[name] = consumed;
|
||||
}
|
||||
return { positions, consumedIndices, consumed: idx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a subgraph-template node's widgets_values entries belonging to widget slots that are
|
||||
* actually linked (see _isLinkedWidgetName), so convertUiToApi's own widget-mapping loop — which
|
||||
* assumes the "modern" format where linked widgets contribute zero widgets_values entries — maps
|
||||
* every remaining widget to the right value instead of drifting by however many linked entries
|
||||
* were left in place. No-op when widgets_values already follows the modern format (nothing to
|
||||
* strip) or the node has no widget inputs at all.
|
||||
*/
|
||||
function _stripLegacyLinkedWidgetValues(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets) {
|
||||
const widgetsValues = remappedNode.widgets_values;
|
||||
const allWidgetInputs = (remappedNode.inputs || []).filter((i) => i.widget);
|
||||
if (!widgetsValues || allWidgetInputs.length === 0) return;
|
||||
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
|
||||
// If skipping linked widgets already accounts for every entry, widgets_values is already in
|
||||
// the modern (no dangling linked entries) format — nothing to strip.
|
||||
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
if (skipSim.consumed === widgetsValues.length) return;
|
||||
|
||||
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
|
||||
// indices the linked ones occupy — including any control_after_generate extra a linked
|
||||
// widget's own turn would have consumed, which must be dropped too (see _simulateWidgetValues).
|
||||
const fullSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, false, comboExpander);
|
||||
const linkedIdx = new Set();
|
||||
for (const name of widgetNames) {
|
||||
if (isLinkedName(name)) {
|
||||
for (const i of fullSim.consumedIndices[name] || []) linkedIdx.add(i);
|
||||
}
|
||||
}
|
||||
if (linkedIdx.size > 0) {
|
||||
remappedNode.widgets_values = widgetsValues.filter((_, i) => !linkedIdx.has(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the widgets_values array index that corresponds to a given widget input name on a
|
||||
* subgraph-template node, for injecting an outer subgraph-node's un-linked widget value into it.
|
||||
* Assumes _stripLegacyLinkedWidgetValues has already normalized widgets_values to the modern
|
||||
* format (linked widgets contribute zero entries), matching what convertUiToApi's own
|
||||
* widget-mapping loop expects.
|
||||
*/
|
||||
function _findInjectedWidgetIndex(objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetName) {
|
||||
const widgetNames = _getWidgetInputNames(objectInfo, remappedNode.type);
|
||||
const widgetTypes = _getWidgetInputTypes(objectInfo, remappedNode.type);
|
||||
const widgetsValues = remappedNode.widgets_values || [];
|
||||
const isLinkedName = (name) => _isLinkedWidgetName(remappedNode, sgDef, origInternalId, redirectedTargets, name);
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, remappedNode.type, name, selectedKey);
|
||||
const sim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
return sim.positions[targetName] ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a subgraph-containing UI workflow into a plain UI workflow.
|
||||
*
|
||||
@@ -123,10 +341,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;
|
||||
|
||||
@@ -162,16 +387,20 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Map subgraph input ports → internal target {nodeId, slot} ===
|
||||
// A single boundary port can fan out to MULTIPLE internal targets (e.g. a "width" pin
|
||||
// feeding both EmptyFlux2LatentImage.width as a widget AND a PreviewAny.source wildcard
|
||||
// link purely for canvas display) — collect all of them, not just the last one seen.
|
||||
const inputPortTargets = {};
|
||||
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
|
||||
const port = sgDef.inputs[portIdx];
|
||||
for (const linkId of (port.linkIds || [])) {
|
||||
const iLink = (sgDef.links || []).find(l => l.id === linkId);
|
||||
if (iLink && iLink.origin_id === -10) {
|
||||
inputPortTargets[portIdx] = {
|
||||
if (!inputPortTargets[portIdx]) inputPortTargets[portIdx] = [];
|
||||
inputPortTargets[portIdx].push({
|
||||
targetNodeId: nodeIdRemap[iLink.target_id],
|
||||
targetSlot: iLink.target_slot,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +421,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];
|
||||
@@ -200,10 +435,15 @@ function _flattenSubgraphs(workflow) {
|
||||
? (sgDef.inputs || []).findIndex(p => p.name === inputName)
|
||||
: -1;
|
||||
if (portIdx === -1) portIdx = dstSlot;
|
||||
const target = inputPortTargets[portIdx];
|
||||
// Only the port's first fanned-out target is redirected here (a real outer link
|
||||
// feeding a fanned-out port is rare — not seen in any known template so far).
|
||||
// The far more common case (an outer *widget value*, no real link, fanning out to
|
||||
// multiple internal targets) is handled fully below, for every target.
|
||||
const target = inputPortTargets[portIdx]?.[0];
|
||||
if (target) {
|
||||
link[3] = target.targetNodeId;
|
||||
link[4] = target.targetSlot;
|
||||
redirectedTargets.add(`${target.targetNodeId}:${target.targetSlot}`);
|
||||
}
|
||||
}
|
||||
if (link[1] === node.id) {
|
||||
@@ -217,10 +457,102 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
// === Add internal nodes (remapped IDs) ===
|
||||
const remappedByOrigId = {};
|
||||
for (const iNode of sgDef.nodes) {
|
||||
const remapped = JSON.parse(JSON.stringify(iNode));
|
||||
remapped.id = nodeIdRemap[iNode.id];
|
||||
|
||||
// Propagate the outer subgraph-node's Mute/Bypass mode onto every internal node.
|
||||
// Without this, muting or bypassing a whole subgraph instance (e.g. disabling one of
|
||||
// several parallel Flux.2 sampling pipelines via Ctrl-B) leaves its internal nodes at
|
||||
// their template-authored mode (normally 0/enabled), so convertUiToApi's mode check
|
||||
// below never excludes them — they end up in the API payload and can be picked up by
|
||||
// node-existence-based UI logic (e.g. "first matching node found") ahead of the nodes
|
||||
// actually driving the enabled pipeline.
|
||||
if (node.mode === 2 || node.mode === 4) {
|
||||
remapped.mode = node.mode;
|
||||
}
|
||||
|
||||
// Some subgraph-template nodes store widgets_values in the "legacy full" format (one
|
||||
// entry per widget input, including ones that are actually linked — either redirected
|
||||
// from an outer parent-graph link, or wired to another internal node within the same
|
||||
// subgraph template, e.g. EmptyFlux2LatentImage.width <- GetImageSize). Strip those
|
||||
// entries so convertUiToApi's widget-mapping loop (which assumes the "modern" format:
|
||||
// linked widgets contribute zero entries) doesn't drift every subsequent widget by
|
||||
// however many linked entries were left in place — see _stripLegacyLinkedWidgetValues.
|
||||
// Boundary links that terminate in a plain widget value (e.g. prompt text) are NOT
|
||||
// treated as linked here — those resolve via the outer-value injection step below.
|
||||
_stripLegacyLinkedWidgetValues(objectInfo, remapped, sgDef, String(iNode.id), redirectedTargets);
|
||||
|
||||
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. A port can fan
|
||||
// out to several internal targets (see inputPortTargets collection above) — apply the same
|
||||
// value to every one of them, not just the first. Iterating per PORT (rather than per
|
||||
// outer widget) matters for unfilled templates whose outer widgets_values is empty ([]) —
|
||||
// there is then no outer override to apply, but the fan-out's widget-type target (e.g.
|
||||
// EmptyFlux2LatentImage.width) still holds its own template-authored default, and that
|
||||
// same value is what a sibling non-widget target (e.g. a PreviewAny used to render the
|
||||
// value into a text template) needs too.
|
||||
const outerWidgetInputs = (node.inputs || []).filter((inp) => inp.widget);
|
||||
for (let portIdx = 0; portIdx < (sgDef.inputs || []).length; portIdx++) {
|
||||
const targets = inputPortTargets[portIdx];
|
||||
if (!targets || targets.length === 0) continue;
|
||||
|
||||
const port = sgDef.inputs[portIdx];
|
||||
const outerInp = outerWidgetInputs.find((inp) => inp.name === port.name);
|
||||
const outerWidgetIdx = outerInp ? outerWidgetInputs.indexOf(outerInp) : -1;
|
||||
const hasOuterOverride = !!outerInp && outerInp.link == null && outerWidgetIdx !== -1
|
||||
&& outerWidgetIdx < (node.widgets_values || []).length;
|
||||
const outerValue = hasOuterOverride ? node.widgets_values[outerWidgetIdx] : undefined;
|
||||
|
||||
// Value to apply to non-widget (wildcard) fan-out targets: the outer override if
|
||||
// present, otherwise whatever a sibling widget-type target ends up holding (its own
|
||||
// unmodified template default, when there is no outer override).
|
||||
let fallbackLiteral = outerValue;
|
||||
|
||||
for (const target of targets) {
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) continue;
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef?.widget) continue;
|
||||
|
||||
// Compute where in widgets_values this widget's value actually lives — accounting
|
||||
// for linked slots (which consume no entry) and control_after_generate-style
|
||||
// extras (which shift later indices by one). See _findInjectedWidgetIndex for why
|
||||
// a plain name-index lookup silently corrupts sibling widgets here.
|
||||
const iWidgetIdx = _findInjectedWidgetIndex(
|
||||
objectInfo, remappedNode, sgDef, origInternalId, redirectedTargets, targetInputDef.name
|
||||
);
|
||||
if (iWidgetIdx === -1) continue;
|
||||
|
||||
if (!remappedNode.widgets_values) remappedNode.widgets_values = [];
|
||||
if (hasOuterOverride) remappedNode.widgets_values[iWidgetIdx] = outerValue;
|
||||
if (fallbackLiteral === undefined) fallbackLiteral = remappedNode.widgets_values[iWidgetIdx];
|
||||
}
|
||||
|
||||
if (fallbackLiteral === undefined) continue;
|
||||
|
||||
for (const target of targets) {
|
||||
const origInternalId = String(target.targetNodeId).split(":").pop();
|
||||
const remappedNode = remappedByOrigId[origInternalId];
|
||||
if (!remappedNode) continue;
|
||||
const targetInputDef = (remappedNode.inputs || [])[target.targetSlot];
|
||||
if (!targetInputDef || targetInputDef.widget) continue;
|
||||
|
||||
// Not a widget-representable input — typically a required wildcard ("*") link slot
|
||||
// with no widget UI (e.g. a "Preview as Text" node used to render a width/height
|
||||
// value into a prompt template string). There is no upstream node to link it to
|
||||
// once flattened (the value only ever existed as this outer widget or a sibling
|
||||
// target's own default), so stash it for convertUiToApi() to inject as a plain
|
||||
// literal value instead of a link.
|
||||
if (!remappedNode._wfmLiteralInputs) remappedNode._wfmLiteralInputs = {};
|
||||
remappedNode._wfmLiteralInputs[targetInputDef.name] = fallbackLiteral;
|
||||
}
|
||||
}
|
||||
|
||||
// === Add internal links (object format → array format) ===
|
||||
@@ -253,8 +585,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 +602,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 +633,27 @@ 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;
|
||||
}
|
||||
// Reroute: UI-only passthrough node, no backend node class — including it in the API
|
||||
// payload triggers a "missing_node_type" 400 from ComfyUI. Its wires are already
|
||||
// resolved through via _resolveBypassSource() below, same as a Bypassed node's.
|
||||
if (node.type === "Reroute") 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 +669,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 +735,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 +765,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];
|
||||
}
|
||||
}
|
||||
@@ -407,6 +801,15 @@ export const comfyWorkflow = {
|
||||
inputs["wildcard_text"] = widgets[0];
|
||||
}
|
||||
|
||||
// Literal values for non-widget (e.g. wildcard "*") inputs that a subgraph boundary
|
||||
// widget fanned out to — see the injection step in _flattenSubgraphs for why these
|
||||
// can't be represented as widgets_values entries or resolved as a link.
|
||||
if (node._wfmLiteralInputs) {
|
||||
for (const [k, v] of Object.entries(node._wfmLiteralInputs)) {
|
||||
if (!(k in inputs)) inputs[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
class_type: node.type,
|
||||
inputs,
|
||||
@@ -516,15 +919,48 @@ export const comfyWorkflow = {
|
||||
|
||||
// KSamplerAdvanced uses noise_seed instead of seed
|
||||
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
|
||||
const seedVal = inputs.seed ?? inputs.noise_seed;
|
||||
// seed/steps/cfg/denoise are sometimes wired to Primitive nodes (e.g. a shared
|
||||
// "steps" value switched between two LoRA presets) instead of holding a direct
|
||||
// number — leave those undefined so the settings-tab UI shows "linked" instead of
|
||||
// a link-reference array, and Apply doesn't clobber the link with a literal value.
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title,
|
||||
seed: inputs.seed ?? inputs.noise_seed,
|
||||
seedKey,
|
||||
steps: inputs.steps, cfg: inputs.cfg,
|
||||
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
|
||||
denoise: inputs.denoise,
|
||||
seed: typeof seedVal === "number" ? seedVal : undefined,
|
||||
seedKey, seedNodeId: id,
|
||||
steps: typeof inputs.steps === "number" ? inputs.steps : undefined, stepsNodeId: id,
|
||||
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined, cfgNodeId: id,
|
||||
sampler_name: inputs.sampler_name, samplerNodeId: id,
|
||||
scheduler: inputs.scheduler, schedulerNodeId: id,
|
||||
denoise: typeof inputs.denoise === "number" ? inputs.denoise : undefined, denoiseNodeId: id,
|
||||
});
|
||||
}
|
||||
|
||||
// CFGGuider / BasicGuider / DualCFGGuider — used by "Advanced Sampling" workflows
|
||||
// (Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.) in place of KSampler's
|
||||
// positive/negative inputs. These feed SamplerCustomAdvanced rather than driving a
|
||||
// sampler themselves, so role propagation must start here too, or CLIPTextEncode
|
||||
// nodes downstream stay "unknown" role.
|
||||
if (ct === "CFGGuider") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
if (ct === "BasicGuider") {
|
||||
if (Array.isArray(inputs.conditioning)) samplerPositiveRef[inputs.conditioning[0]] = true;
|
||||
}
|
||||
// DualCFGGuider (HiDream E1 instruct-pix2pix style edit): cond1/cond2 both derive from
|
||||
// the positive text (often via InstructPixToPixConditioning), negative is separate —
|
||||
// cond2 is left unmarked since it doesn't cleanly map to either role.
|
||||
if (ct === "DualCFGGuider") {
|
||||
if (Array.isArray(inputs.cond1)) samplerPositiveRef[inputs.cond1[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// SamplerCustom (older/simpler counterpart to SamplerCustomAdvanced): holds
|
||||
// positive/negative directly instead of going through a separate Guider node.
|
||||
if (ct === "SamplerCustom") {
|
||||
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
|
||||
@@ -538,6 +974,7 @@ export const comfyWorkflow = {
|
||||
"ControlNetApply", "ControlNetApplyAdvanced",
|
||||
"IPAdapterApply", "IPAdapterApplyFaceID",
|
||||
"StyleModelApply",
|
||||
"FluxGuidance", "FluxKontextMultiReferenceLatentMethod",
|
||||
]);
|
||||
|
||||
for (let iter = 0; iter < 5; iter++) {
|
||||
@@ -592,6 +1029,60 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1 edit) — unlike COND_PASSTHROUGH nodes,
|
||||
// this has *separate* positive/negative inputs feeding separate outputs, so each
|
||||
// role must route only to its own input (not both) or the negative CLIPTextEncode
|
||||
// would incorrectly get marked positive too via the positive-output branch.
|
||||
if (ct === "InstructPixToPixConditioning") {
|
||||
if (isPos && Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
|
||||
if (isNeg && Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — used by Ernie
|
||||
// Image's prompt-enhancement toggle to pick between the raw PrimitiveStringMultiline
|
||||
// text and an LLM-generated (TextGenerate) rewrite. The rewrite can't be known
|
||||
// statically, so propagate the role through both branches — only the branch that
|
||||
// resolves to a literal string source (e.g. PrimitiveStringMultiline) will end up
|
||||
// producing a prompt_nodes entry in Pass 2.
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// TextEncodeQwenImageEditPlus/TextEncodeQwenImageEdit — propagate role through its
|
||||
// linked "prompt" input when prompt-enhancement wiring feeds it via a switch chain
|
||||
// instead of a literal string (e.g. Krea-2's image-reference workflow).
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
const tv = inputs.prompt;
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
// PreviewAny — generic value tap/relay used mid-chain in prompt-enhancement wiring
|
||||
// (e.g. Krea-2's "Refine Prompt?" switch output tap); pass role through its
|
||||
// "source" input unchanged.
|
||||
if (ct === "PreviewAny") {
|
||||
const sv = inputs.source;
|
||||
if (Array.isArray(sv)) {
|
||||
if (isPos) samplerPositiveRef[sv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[sv[0]] = true;
|
||||
}
|
||||
}
|
||||
// StringConcatenate — appends e.g. a LoRA trigger word onto the prompt text
|
||||
// (Krea-2); propagate role through both operands so the upstream literal is found.
|
||||
if (ct === "StringConcatenate") {
|
||||
for (const key of ["string_a", "string_b"]) {
|
||||
const tv = inputs[key];
|
||||
if (Array.isArray(tv)) {
|
||||
if (isPos) samplerPositiveRef[tv[0]] = true;
|
||||
if (isNeg) samplerNegativeRef[tv[0]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,6 +1104,72 @@ export const comfyWorkflow = {
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
// --- Advanced Sampler: SamplerCustomAdvanced ---
|
||||
// "Advanced Sampling" pattern used by Flux.1/Flux.2, SD3.5, Chroma, HiDream E1, etc.:
|
||||
// instead of one KSampler node holding seed/steps/cfg/sampler/scheduler, those values
|
||||
// are spread across RandomNoise (noise_seed) → a Guider node (cfg) → KSamplerSelect
|
||||
// (sampler_name) → a *Scheduler node (steps/denoise), all feeding SamplerCustomAdvanced.
|
||||
// Collect them into one composite entry so Settings-tab editing still works; each
|
||||
// value's *NodeId tracks which real node to write back to (null = no such control
|
||||
// exists on this workflow, e.g. Flux2Scheduler has no named "scheduler" combo).
|
||||
if (ct === "SamplerCustomAdvanced") {
|
||||
const noiseId = Array.isArray(inputs.noise) ? String(inputs.noise[0]) : null;
|
||||
const guiderId = Array.isArray(inputs.guider) ? String(inputs.guider[0]) : null;
|
||||
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
|
||||
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
|
||||
|
||||
const noiseInputs = (noiseId && workflow[noiseId]?.inputs) || {};
|
||||
const guiderNode = guiderId ? workflow[guiderId] : null;
|
||||
const guiderInputs = guiderNode?.inputs || {};
|
||||
// DualCFGGuider (HiDream E1 instruct-pix2pix edit) has no plain "cfg" input — its
|
||||
// two CFG knobs are cfg_conds/cfg_cond2_negative. Use cfg_conds as the
|
||||
// representative value so Settings still shows/edits *something* meaningful.
|
||||
const guiderCfgKey = guiderNode?.class_type === "DualCFGGuider" ? "cfg_conds" : "cfg";
|
||||
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
|
||||
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
|
||||
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title, advanced: true,
|
||||
seed: typeof noiseInputs.noise_seed === "number" ? noiseInputs.noise_seed : undefined,
|
||||
seedKey: "noise_seed", seedNodeId: noiseId,
|
||||
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
|
||||
stepsNodeId: schedulerId,
|
||||
cfg: typeof guiderInputs[guiderCfgKey] === "number" ? guiderInputs[guiderCfgKey] : undefined,
|
||||
cfgNodeId: guiderCfgKey in guiderInputs ? guiderId : null,
|
||||
cfgKey: guiderCfgKey,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
scheduler: undefined, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced Sampler: SamplerCustom ---
|
||||
// Older/simpler counterpart to SamplerCustomAdvanced: no RandomNoise/Guider
|
||||
// indirection — noise_seed and cfg are its own widgets, and positive/negative feed it
|
||||
// directly. sampler_name/steps/scheduler/denoise still come from separate
|
||||
// KSamplerSelect/*Scheduler nodes via its sampler/sigmas inputs.
|
||||
if (ct === "SamplerCustom") {
|
||||
const samplerSelId = Array.isArray(inputs.sampler) ? String(inputs.sampler[0]) : null;
|
||||
const schedulerId = Array.isArray(inputs.sigmas) ? String(inputs.sigmas[0]) : null;
|
||||
const samplerSelInputs = (samplerSelId && workflow[samplerSelId]?.inputs) || {};
|
||||
const schedulerInputs = (schedulerId && workflow[schedulerId]?.inputs) || {};
|
||||
|
||||
result.sampler_nodes.push({
|
||||
id, type: ct, title, advanced: true,
|
||||
seed: typeof inputs.noise_seed === "number" ? inputs.noise_seed : undefined,
|
||||
seedKey: "noise_seed", seedNodeId: id,
|
||||
steps: typeof schedulerInputs.steps === "number" ? schedulerInputs.steps : undefined,
|
||||
stepsNodeId: schedulerId,
|
||||
cfg: typeof inputs.cfg === "number" ? inputs.cfg : undefined,
|
||||
cfgNodeId: id,
|
||||
sampler_name: samplerSelInputs.sampler_name, samplerNodeId: samplerSelId,
|
||||
scheduler: schedulerInputs.scheduler, schedulerNodeId: "scheduler" in schedulerInputs ? schedulerId : null,
|
||||
denoise: typeof schedulerInputs.denoise === "number" ? schedulerInputs.denoise : undefined,
|
||||
denoiseNodeId: "denoise" in schedulerInputs ? schedulerId : null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Checkpoint nodes ---
|
||||
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
|
||||
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
|
||||
@@ -679,13 +1236,51 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen-based image+text encoder
|
||||
if (ct === "TextEncodeQwenImageEditPlus") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: inputs.prompt || "",
|
||||
textKey: "prompt",
|
||||
});
|
||||
// Qwen-based image+text encoder (Plus variant takes up to 3 reference images;
|
||||
// the base variant takes one — both share the same "prompt" widget). Only add when
|
||||
// "prompt" is a direct string; if linked (e.g. Krea-2's prompt-enhancement wiring),
|
||||
// the upstream literal node is resolved instead via the role propagation above.
|
||||
if (ct === "TextEncodeQwenImageEditPlus" || ct === "TextEncodeQwenImageEdit") {
|
||||
const textVal = inputs.prompt;
|
||||
if (typeof textVal === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title, role: getRole(),
|
||||
text: textVal, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TextEncodeBooguEdit (Boogu image-edit model) — prompt + negative_prompt in one node
|
||||
if (ct === "TextEncodeBooguEdit") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
text: inputs.prompt, textKey: "prompt",
|
||||
});
|
||||
}
|
||||
if (typeof inputs.negative_prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [negative]`, role: "negative",
|
||||
text: inputs.negative_prompt, textKey: "negative_prompt",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mage-Flow text encoder — prompt + negative_prompt in one node (fixed roles,
|
||||
// unlike KSampler-derived getRole() since both wires originate from this node)
|
||||
if (ct === "TextEncodeMageFlowEdit") {
|
||||
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
|
||||
@@ -741,10 +1336,29 @@ export const comfyWorkflow = {
|
||||
}
|
||||
|
||||
// --- Latent nodes ---
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
|
||||
// EmptyFlux2LatentImage (Flux.2 templates) often has width/height wired to a
|
||||
// GetImageSize node (auto-follows the reference image) rather than a direct number —
|
||||
// leave those as undefined, same treatment as TextEncodeMageFlowEdit below, so the
|
||||
// settings-tab UI shows "linked" instead of a stale default and Apply doesn't clobber it.
|
||||
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage" || ct === "EmptyFlux2LatentImage") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: inputs.width, height: inputs.height,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
height: typeof inputs.height === "number" ? inputs.height : undefined,
|
||||
batch_size: inputs.batch_size,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
+167
-3
@@ -7,10 +7,13 @@ import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { escapeHtml, setupSearchClearBtn, getAiBackendDefaultUrl } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
|
||||
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
|
||||
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +83,133 @@ window._wfmReceiveImageForI2I = async (blob, name, workflowData, workflowFilenam
|
||||
}
|
||||
};
|
||||
|
||||
// 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 };
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しにコマの設定(シーン・要素・セリフ等)を受け取り、
|
||||
// AIタブの設定タブと同じLLM接続設定(Ollama/LM Studio)で画像生成プロンプトの下書きを
|
||||
// 生成して返す(半自動マンガ作成の「L」ボタン連携用)。
|
||||
// Comic Creater側のスクリプトタブ「L」ボタンから
|
||||
// iframe.contentWindow._wfmReceiveLLMPromptRequest(context) として呼ばれる。
|
||||
// context: { scene, elements, dialogues, existingPrompt }(いずれも文字列、空文字可)
|
||||
window._wfmReceiveLLMPromptRequest = async (context) => {
|
||||
try {
|
||||
const settings = loadAiSettings();
|
||||
const { backend = "ollama", backendUrl, model } = settings;
|
||||
const url = backendUrl || getAiBackendDefaultUrl(backend);
|
||||
if (!isValidBackendUrl(url)) return { ok: false, message: "AI settings: invalid backend URL (configure it in the Settings tab)" };
|
||||
if (!model) return { ok: false, message: "AI settings: no model selected (configure it in the Settings tab)" };
|
||||
|
||||
const { scene = "", elements = "", dialogues = "", existingPrompt = "" } = context || {};
|
||||
const prompt = [
|
||||
"Create a concise Stable Diffusion image generation prompt (English, comma-separated tags/phrases describing scene, composition, character appearance and action) for one manga panel, based on the following context.",
|
||||
"Output only the prompt text, nothing else.",
|
||||
scene ? `Scene: ${scene}` : null,
|
||||
elements ? `Characters/elements: ${elements}` : null,
|
||||
dialogues ? `Dialogue: ${dialogues}` : null,
|
||||
existingPrompt ? `Current draft prompt (revise/improve it): ${existingPrompt}` : null,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
const text = (result || "").trim();
|
||||
if (!text) return { ok: false, message: "LLM returned an empty response" };
|
||||
return { ok: true, text };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像プロンプト・サイズを受け取り、Generate UIで
|
||||
// テキストから画像を生成して結果URLを返す(半自動マンガ作成のコマ単位バッチ画像生成用)。
|
||||
// Comic Creater側のスクリプトタブ「画像を一括生成」から
|
||||
// iframe.contentWindow._wfmReceiveGenerateRequest(prompt, width, height, negative, workflowData?, workflowFilename?)
|
||||
// として呼ばれる。I2I/Inpaintと異なり画像アップロードは行わない(txt2img)。
|
||||
// workflowData が渡された場合(Comic Creater側のT2I設定でデフォルトワークフローが有効な時)は、
|
||||
// 実行前にそのワークフローを読み込む。渡されない場合はGenerate UIに現在ロード中のワークフローをそのまま使う。
|
||||
window._wfmReceiveGenerateRequest = async (prompt, width, height, negative, workflowData, workflowFilename) => {
|
||||
try {
|
||||
if (!window._wfmGenerateTab?.generate) return { ok: false, message: "Generate UI is not ready yet" };
|
||||
|
||||
if (workflowData) {
|
||||
try {
|
||||
if (!comfyUI.connected) {
|
||||
await comfyUI.checkConnection();
|
||||
}
|
||||
if (comfyUI.connected && (!comfyEditor.models.checkpoints || comfyEditor.models.checkpoints.length === 0)) {
|
||||
await comfyEditor.loadModelLists();
|
||||
}
|
||||
await loadWorkflowIntoEditor(workflowData, workflowFilename || "workflow.json");
|
||||
} catch (e) {
|
||||
console.warn("[T2I] failed to load default workflow:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!comfyUI.currentWorkflow || !analysis) return { ok: false, message: "No workflow loaded in Generate UI" };
|
||||
|
||||
const workflow = JSON.parse(JSON.stringify(comfyUI.currentWorkflow));
|
||||
const latent = analysis.latent_nodes?.[0];
|
||||
if (latent && width && height && workflow[latent.id]) {
|
||||
workflow[latent.id].inputs.width = width;
|
||||
workflow[latent.id].inputs.height = height;
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", prompt || "", { workflow, analysis });
|
||||
if (negative) comfyEditor.setPromptText("negative", negative, { workflow, analysis });
|
||||
|
||||
await window._wfmGenerateTab.generate(workflow);
|
||||
|
||||
const resultUrl = document.getElementById("wfm-gen-result-img")?.src;
|
||||
if (!resultUrl) return { ok: false, message: "No result image produced" };
|
||||
return { ok: true, url: resultUrl };
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
// ── ページング ────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50;
|
||||
let _renderedCount = 0;
|
||||
@@ -273,7 +403,7 @@ function renderTreeNode(node, container, depth, isRoot) {
|
||||
// フォルダ名
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? `[root] ${node.name}` : node.name;
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
if (node.image_count > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
@@ -783,15 +913,29 @@ async function loadImageDetail(img) {
|
||||
state.embeddedWorkflow = wfRes.has_workflow ? wfRes.workflow : null;
|
||||
renderWorkflowJson(state.embeddedWorkflow);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(metaRes.image_prompt);
|
||||
} catch (e) {
|
||||
renderWorkflowJson(null);
|
||||
_updateCopyCanvasBtn();
|
||||
renderImagePromptSection(null);
|
||||
}
|
||||
|
||||
// グループタブ更新
|
||||
renderDetailGroup(img);
|
||||
}
|
||||
|
||||
function renderImagePromptSection(imagePrompt) {
|
||||
const section = document.getElementById("wfm-gallery-image-prompt-section");
|
||||
const text = document.getElementById("wfm-gallery-image-prompt-text");
|
||||
if (!section || !text) return;
|
||||
if (imagePrompt) {
|
||||
text.textContent = imagePrompt;
|
||||
section.style.display = "";
|
||||
} else {
|
||||
section.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagsDisplay(tags) {
|
||||
const container = document.getElementById("wfm-gallery-tags-display");
|
||||
container.innerHTML = "";
|
||||
@@ -1478,6 +1622,9 @@ export function initGalleryTab() {
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
_initGallerySubtabToggle();
|
||||
initImagePromptTab();
|
||||
initStyleCatalogTab();
|
||||
}
|
||||
|
||||
let _initialized = false;
|
||||
@@ -1489,6 +1636,23 @@ function onGalleryTabActivated() {
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
// ── サブタブ切替 (Output / ImagePrompt / Style_Catalog) ────────────────────────
|
||||
|
||||
function _initGallerySubtabToggle() {
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.gallerySubtab;
|
||||
document.querySelectorAll(".wfm-gallery-subtab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById("wfm-gallery-panel-output")?.classList.toggle("active", target === "output");
|
||||
document.getElementById("wfm-gallery-panel-imageprompt")?.classList.toggle("active", target === "imageprompt");
|
||||
document.getElementById("wfm-gallery-panel-styleCatalog")?.classList.toggle("active", target === "styleCatalog");
|
||||
if (target === "imageprompt") activateImagePromptTab();
|
||||
if (target === "styleCatalog") activateStyleCatalogTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// リフレッシュ
|
||||
document.getElementById("wfm-gallery-refresh-btn")?.addEventListener("click", () => {
|
||||
@@ -1690,7 +1854,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形式のみ)
|
||||
|
||||
+324
-54
@@ -9,44 +9,8 @@ import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { initFeederTab, refreshFeederNodeList } from "./feeder-tab.js";
|
||||
import { getSettings, readJsonStorage } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveToEagle(imageUrl, name, tags = []) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
import { initLabTab, refreshLabLiveDefaults } from "./lab-tab.js";
|
||||
import { getSettings, readJsonStorage, escapeHtml, getEagleSettings, saveToEagle } from "./util.js";
|
||||
|
||||
// ============================================
|
||||
// Gallery Metadata - ワークフロー保存
|
||||
@@ -218,25 +182,58 @@ 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
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow);
|
||||
// resetStackMode: 新規ワークフロー読み込み時のみ、LoraManagerノードのloras/text状態を
|
||||
// スナップショットし、Stackグループ使用チェックボックスをOFFへリセットする
|
||||
comfyEditor.renderAll(comfyUI.currentAnalysis, apiWorkflow, { resetStackMode: true });
|
||||
|
||||
// Update raw JSON with highlight
|
||||
const rawTextarea = document.getElementById("wfm-gen-raw-json");
|
||||
@@ -247,6 +244,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 +281,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;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1265,12 @@ async function _loadStyles() {
|
||||
_rebuildStyleList();
|
||||
}
|
||||
|
||||
// Promptタブ「Style」サブタブでスタイルを作成・編集・削除した後に呼ばれる。
|
||||
// GenerateUIのStyleドロップダウン・Batchタブのチェックリストを最新の状態へ再同期する。
|
||||
export async function refreshStylesList() {
|
||||
await _loadStyles();
|
||||
}
|
||||
|
||||
function _renderStyleDropdown() {
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (!sel) return;
|
||||
@@ -1268,6 +1299,21 @@ function _rebuildStyleList() {
|
||||
);
|
||||
}
|
||||
|
||||
// Style Catalogギャラリーの「Select as Style」から呼ばれる。名前が一致する登録済み
|
||||
// Styleを探し、GenerateUIのStyleドロップダウンをそれに切り替えてタブへ移動する。
|
||||
// 画像に埋め込まれたプロンプトを直接適用するのではなく、既存Styleシステムへの
|
||||
// 視覚的なショートカットとして機能する(元のStyle JSONが変更・削除されても影響を受けない)。
|
||||
export function selectStyleByName(name) {
|
||||
const style = _stylesData.find((s) => s.name === name);
|
||||
if (!style) return false;
|
||||
const sel = document.getElementById("wfm-style-select");
|
||||
if (sel) sel.value = name;
|
||||
const enabled = document.getElementById("wfm-style-enabled");
|
||||
if (enabled) enabled.checked = true;
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _applyNamedStyle(workflow, style) {
|
||||
if (!style) return workflow;
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
@@ -1368,7 +1414,7 @@ async function _expandWildcardText(text) {
|
||||
|
||||
const _IMPACT_WC_TYPES = new Set(["ImpactWildcardEncode", "ImpactWildcardProcessor"]);
|
||||
|
||||
async function _expandWildcardsInWorkflow(workflow) {
|
||||
export async function _expandWildcardsInWorkflow(workflow) {
|
||||
let hasWildcard = false;
|
||||
outer: for (const node of Object.values(workflow)) {
|
||||
if (_IMPACT_WC_TYPES.has(node.class_type)) continue;
|
||||
@@ -1412,7 +1458,7 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
const baseWorkflow = workflowOverride || comfyUI.currentWorkflow;
|
||||
const workflowExpanded = await _expandWildcardsInWorkflow({ ...baseWorkflow });
|
||||
const workflowForGenerate = _applyStyleToWorkflow(workflowExpanded);
|
||||
const { images, seed } = await comfyUI.generate(
|
||||
const { images, seed, svgOutputs } = await comfyUI.generate(
|
||||
workflowForGenerate,
|
||||
{
|
||||
seedMode,
|
||||
@@ -1430,18 +1476,49 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
|
||||
if (progressBar) progressBar.style.width = "100%";
|
||||
|
||||
if (images.length > 0) {
|
||||
const blob = await comfyUI.getImageBlob(images[0]);
|
||||
// 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;
|
||||
@@ -1456,10 +1533,18 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
}
|
||||
}
|
||||
|
||||
if (getEagleSettings().autoSave && images.length > 0) {
|
||||
for (const img of images) {
|
||||
if (getEagleSettings().autoSave && outputImages.length > 0) {
|
||||
for (const img of outputImages) {
|
||||
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
|
||||
saveToEagle(viewUrl, img.filename);
|
||||
saveToEagle(viewUrl, img.filename, [], img);
|
||||
}
|
||||
}
|
||||
|
||||
if (getEagleSettings().autoSave && svgOutputs?.length > 0) {
|
||||
// comfyui-tosvg の "Save SVG String" は絶対パスを直接返すため、/view を介さず
|
||||
// サーバー側でそのパスを検証した上で addFromPath させる
|
||||
for (const svg of svgOutputs) {
|
||||
saveToEagle("", svg.filename, [], { filename: svg.filename, localPath: svg.path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,6 +1553,8 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
}
|
||||
|
||||
if (!silent) showToast(t("generationComplete"), "success");
|
||||
|
||||
return { images, seed };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -1475,7 +1562,9 @@ async function _coreGenerate(silent = false, workflowOverride = null) {
|
||||
// ============================================
|
||||
|
||||
// 汎用バッチループ: items を順に applyFn(item) → _coreGenerate() で処理する
|
||||
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
// onResultFn(item, {images, seed}) が渡された場合、各アイテムの生成結果ごとに呼び出す
|
||||
// (Style Catalogのカタログ作成が、生成画像をスタイル名で保存するために使用)
|
||||
async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x), onResultFn = null) {
|
||||
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
|
||||
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
|
||||
const batchCount = document.getElementById("wfm-ckpt-batch-count");
|
||||
@@ -1510,7 +1599,8 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
|
||||
try {
|
||||
await applyFn(item);
|
||||
await _coreGenerate(true);
|
||||
const result = await _coreGenerate(true);
|
||||
if (onResultFn) await onResultFn(item, result);
|
||||
completed++;
|
||||
} catch (err) {
|
||||
if (_ckptBatch.aborted) break;
|
||||
@@ -1539,6 +1629,157 @@ async function _runBatchLoop(items, applyFn, labelFn = (x) => String(x)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Style Catalog 作成
|
||||
// ============================================
|
||||
// Batchタブ「Style」サブタブで選択済みのスタイル(_batchStyleSelected)を対象に、
|
||||
// 現在ロード中のワークフローでそれぞれ生成し、結果をスタイル名でws_style_catalog
|
||||
// 配下の指定フォルダへ保存する(Style Catalogギャラリーからの視覚的スタイル選択に使う)。
|
||||
|
||||
function _blobToDataUrl(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function _flattenFolderTree(node, acc = [], isRoot = true) {
|
||||
acc.push({ path: isRoot ? "[root]" : node.path, abs_path: node.abs_path });
|
||||
(node.children || []).forEach((c) => _flattenFolderTree(c, acc, false));
|
||||
return acc;
|
||||
}
|
||||
|
||||
async function openCatalogCreateModal() {
|
||||
const list = _stylesData.filter((s) => _batchStyleSelected.has(s.name));
|
||||
if (list.length === 0) {
|
||||
showToast(t("catalogCreateNoStyles"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let catalogRoot = null;
|
||||
let folderTree = null;
|
||||
try {
|
||||
const rootRes = await fetch("/wfm/gallery/style-catalog/root");
|
||||
const rootData = await rootRes.json();
|
||||
catalogRoot = rootData.root;
|
||||
if (!catalogRoot) throw new Error(rootData.error || "Style Catalog root not resolved");
|
||||
const treeRes = await fetch(`/wfm/gallery/folders?root=${encodeURIComponent(catalogRoot)}`);
|
||||
folderTree = await treeRes.json();
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderOptions = folderTree && !folderTree.error ? _flattenFolderTree(folderTree) : [];
|
||||
|
||||
const html = `
|
||||
<p style="margin:0 0 10px;font-size:13px;color:var(--wfm-text-secondary);">${t("catalogCreateSelectedCount", list.length)}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="radio" name="wfm-catalog-dest" value="new" checked>
|
||||
${t("catalogCreateNewFolder")}
|
||||
</label>
|
||||
<input type="text" id="wfm-catalog-new-folder-name" class="wfm-input" placeholder="folder name" style="margin-left:22px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="radio" name="wfm-catalog-dest" value="existing" ${folderOptions.length === 0 ? "disabled" : ""}>
|
||||
${t("catalogCreateExistingFolder")}
|
||||
</label>
|
||||
<select id="wfm-catalog-existing-folder" class="wfm-input" style="margin-left:22px;" ${folderOptions.length === 0 ? "disabled" : ""}>
|
||||
${folderOptions.map((f) => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.path)}</option>`).join("")}
|
||||
</select>
|
||||
<hr class="wfm-group-divider" style="margin:4px 0;">
|
||||
<label style="display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="wfm-catalog-skip-output">
|
||||
${t("catalogCreateSkipOutput")}
|
||||
</label>
|
||||
</div>
|
||||
<button id="wfm-catalog-run-btn" class="wfm-btn wfm-btn-primary" style="width:100%;margin-top:14px;">${t("catalogCreateRun")}</button>
|
||||
`;
|
||||
openModal(t("catalogCreateTitle"), html);
|
||||
|
||||
document.getElementById("wfm-catalog-run-btn")?.addEventListener("click", async () => {
|
||||
const destType = document.querySelector('input[name="wfm-catalog-dest"]:checked')?.value;
|
||||
let destFolder = null;
|
||||
|
||||
if (destType === "new") {
|
||||
const name = document.getElementById("wfm-catalog-new-folder-name")?.value.trim();
|
||||
if (!name) {
|
||||
showToast(t("catalogCreateFolderNameRequired"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/wfm/gallery/folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent: catalogRoot, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Failed to create folder");
|
||||
destFolder = data.path;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
destFolder = document.getElementById("wfm-catalog-existing-folder")?.value || null;
|
||||
if (!destFolder) {
|
||||
showToast(t("catalogCreateFolderRequired"), "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const skipOutput = document.getElementById("wfm-catalog-skip-output")?.checked || false;
|
||||
|
||||
closeModal();
|
||||
await _runCatalogCreate(list, destFolder, skipOutput);
|
||||
});
|
||||
}
|
||||
|
||||
async function _runCatalogCreate(list, destFolder, skipOutput) {
|
||||
try {
|
||||
await _runBatchLoop(
|
||||
list,
|
||||
(style) => { _batchStyleOverride = style; },
|
||||
(s) => s.name,
|
||||
async (style, result) => {
|
||||
const outputImages = (result?.images || []).filter((img) => img.type !== "temp");
|
||||
if (outputImages.length === 0) return;
|
||||
const srcImage = outputImages[0];
|
||||
const blob = await comfyUI.getImageBlob(srcImage);
|
||||
const dataUrl = await _blobToDataUrl(blob);
|
||||
const res = await fetch("/wfm/gallery/image/save-to-folder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder: destFolder, filename: style.name, imageData: dataUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Failed to save catalog image");
|
||||
|
||||
// Outputフォルダに元画像を残さないオプション: カタログへのコピー保存が
|
||||
// 成功した後にのみ、ComfyUIのOutputフォルダ側の元ファイルを削除する
|
||||
// (コピー失敗時は削除せず、元画像を保持したままにする)
|
||||
if (skipOutput) {
|
||||
try {
|
||||
await fetch("/wfm/gallery/output-image/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: srcImage.filename,
|
||||
subfolder: srcImage.subfolder || "",
|
||||
type: srcImage.type || "output",
|
||||
}),
|
||||
});
|
||||
} catch { /* 削除失敗はカタログ作成自体を止めない */ }
|
||||
}
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
_batchStyleOverride = null;
|
||||
}
|
||||
}
|
||||
|
||||
// アクティブなバッチタイプに応じてバッチを実行するディスパッチャ
|
||||
async function _runBatchGenerate() {
|
||||
switch (_activeBatchType) {
|
||||
@@ -1762,7 +2003,7 @@ async function handleGenerate(workflowOverride = null) {
|
||||
await _runBatchGenerate();
|
||||
} else {
|
||||
try {
|
||||
await _coreGenerate(false, workflowOverride);
|
||||
return await _coreGenerate(false, workflowOverride);
|
||||
} catch (err) {
|
||||
const progressText = document.getElementById("wfm-gen-progress-text");
|
||||
if (progressText) progressText.textContent = "Error";
|
||||
@@ -1827,6 +2068,25 @@ export async function initGenerateTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Style Catalog 作成ボタン
|
||||
const catalogCreateBtn = document.getElementById("wfm-gen-catalog-create-btn");
|
||||
if (catalogCreateBtn) {
|
||||
catalogCreateBtn.textContent = t("catalogCreateBtn");
|
||||
catalogCreateBtn.addEventListener("click", () => {
|
||||
openCatalogCreateModal().catch((e) => showToast(t("errorWithMsg", e.message), "error"));
|
||||
});
|
||||
}
|
||||
|
||||
// Style Catalog を開くボタン(Gallery タブ → Style_Catalog サブタブへ遷移)
|
||||
const catalogOpenBtn = document.getElementById("wfm-gen-catalog-open-btn");
|
||||
if (catalogOpenBtn) {
|
||||
catalogOpenBtn.textContent = t("catalogOpenBtn");
|
||||
catalogOpenBtn.addEventListener("click", () => {
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click();
|
||||
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="styleCatalog"]')?.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Generate button
|
||||
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", () => {
|
||||
handleGenerate().catch(() => {});
|
||||
@@ -1855,7 +2115,7 @@ export async function initGenerateTab() {
|
||||
function moveRawJsonToTab(tabKey) {
|
||||
const widget = document.getElementById("wfm-gen-rawjson-widget");
|
||||
if (!widget) return;
|
||||
if (tabKey === "feeder" || tabKey === "batch") {
|
||||
if (tabKey === "feeder" || tabKey === "batch" || tabKey === "lab") {
|
||||
widget.style.display = "none";
|
||||
return;
|
||||
}
|
||||
@@ -1879,6 +2139,13 @@ export async function initGenerateTab() {
|
||||
if (target === "model" && comfyUI.currentAnalysis) {
|
||||
comfyEditor.renderLoraPane(comfyUI.currentAnalysis, "wfm-gen-lora-fields");
|
||||
}
|
||||
// Re-render Lab's column grid so Setting 1's live-reflected cells (checkpoint/
|
||||
// VAE/prompt/KSampler) pick up whatever is currently loaded in GenerateUI, not
|
||||
// whatever was loaded the last time Lab was rendered.
|
||||
if (target === "lab") refreshLabLiveDefaults();
|
||||
// Lab has its own Run/Results panel — hide the shared Generate/Seed/Batch column
|
||||
const rightCol = document.querySelector(".wfm-gen-right");
|
||||
if (rightCol) rightCol.style.display = (target === "lab") ? "none" : "";
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2055,6 +2322,9 @@ export async function initGenerateTab() {
|
||||
// Feeder tab
|
||||
await initFeederTab();
|
||||
|
||||
// Lab tab (experimental I2I batch generation)
|
||||
initLabTab();
|
||||
|
||||
// Style dropdown
|
||||
await _loadStyles();
|
||||
|
||||
|
||||
+872
-48
File diff suppressed because it is too large
Load Diff
+104
-1104
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Image Edit Tab - Background Removal
|
||||
* Two backends: a lightweight in-browser model (@imgly/background-removal)
|
||||
* and BiRefNet via the Mask Editor One server endpoints.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class BgRemove {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(newLayer: object) => void} callbacks.onLayerAdded
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/birefnet/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.birefnetAvailable = json.loaded === true || json.model_found === true;
|
||||
} catch {
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const birefnetDisabled = this.birefnetAvailable ? "" : "disabled";
|
||||
const birefnetLabel = this.birefnetAvailable
|
||||
? "BiRefNet (Mask Editor One)"
|
||||
: "BiRefNet (Mask Editor One required)";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<label>Model</label>
|
||||
<select id="ie-bgremove-model" class="ie-opt-select">
|
||||
<option value="imgly">Lightweight (@imgly)</option>
|
||||
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-bgremove-btn">Remove BG</button>
|
||||
</div>
|
||||
<span id="ie-bgremove-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;"></span>
|
||||
`;
|
||||
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this.apply());
|
||||
}
|
||||
|
||||
async apply() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
|
||||
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
|
||||
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
|
||||
const statusEl = document.getElementById("ie-bgremove-status");
|
||||
const btn = document.getElementById("ie-bgremove-btn");
|
||||
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
setStatus("Starting...");
|
||||
|
||||
try {
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
let resultDataUrl;
|
||||
if (model === "imgly") {
|
||||
resultDataUrl = await this._removeImgly(dataUrl, setStatus);
|
||||
} else {
|
||||
resultDataUrl = await this._removeBiRefNet(dataUrl, setStatus);
|
||||
}
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Result image load failed"));
|
||||
i.src = resultDataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (asNew) {
|
||||
const newL = layerMgr.addLayer("image", layer.name + " (no bg)", {
|
||||
contentW: img.width, contentH: img.height,
|
||||
displayW: layer.displayW, displayH: layer.displayH,
|
||||
x: layer.x, y: layer.y,
|
||||
});
|
||||
newL.ctx.drawImage(img, 0, 0);
|
||||
layerMgr.setActive(newL.id);
|
||||
this._cb.onLayerAdded(newL);
|
||||
} else {
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
setStatus("Done!");
|
||||
setTimeout(() => setStatus(""), 3000);
|
||||
showToast("Background removed", "success");
|
||||
|
||||
} catch (err) {
|
||||
setStatus("Error: " + err.message);
|
||||
showToast("BG remove failed: " + err.message, "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _removeImgly(dataUrl, onStatus) {
|
||||
if (!window._wfmImglyRemoveBg) {
|
||||
onStatus("Loading model...");
|
||||
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
|
||||
window._wfmImglyRemoveBg = mod.removeBackground;
|
||||
}
|
||||
onStatus("Processing...");
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const resultBlob = await window._wfmImglyRemoveBg(blob, {
|
||||
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(resultBlob);
|
||||
});
|
||||
}
|
||||
|
||||
async _removeBiRefNet(dataUrl, onStatus) {
|
||||
const NODE_ID = "wfs_bgremove";
|
||||
|
||||
// 1. アクティブレイヤー画像を Mask Editor One のキャッシュに登録
|
||||
onStatus("Sending image...");
|
||||
const storeResp = await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
|
||||
|
||||
// 2. BiRefNet 推論実行
|
||||
onStatus("Running BiRefNet...");
|
||||
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
|
||||
// 3. グレースケールマスク (白=前景) → RGBA PNG に変換して返す
|
||||
onStatus("Compositing...");
|
||||
return await this._applyMaskToImage(dataUrl, json.mask_b64);
|
||||
}
|
||||
|
||||
async _applyMaskToImage(imageB64, maskB64) {
|
||||
const loadImage = src => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
|
||||
img.src = src;
|
||||
});
|
||||
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
|
||||
|
||||
const w = origImg.naturalWidth;
|
||||
const h = origImg.naturalHeight;
|
||||
|
||||
// マスク: グレースケール輝度値 → アルファチャンネル (RGBA 白マスク)
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = w;
|
||||
maskCanvas.height = h;
|
||||
const mc = maskCanvas.getContext("2d");
|
||||
mc.drawImage(maskImg, 0, 0, w, h);
|
||||
const maskData = mc.getImageData(0, 0, w, h);
|
||||
const md = maskData.data;
|
||||
for (let i = 0; i < md.length; i += 4) {
|
||||
md[i + 3] = md[i]; // 輝度 → アルファ
|
||||
md[i] = md[i + 1] = md[i + 2] = 255;
|
||||
}
|
||||
mc.putImageData(maskData, 0, 0);
|
||||
|
||||
// 元画像にマスクを destination-in で適用
|
||||
const out = document.createElement("canvas");
|
||||
out.width = w;
|
||||
out.height = h;
|
||||
const ctx = out.getContext("2d");
|
||||
ctx.drawImage(origImg, 0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.drawImage(maskCanvas, 0, 0, w, h);
|
||||
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Image Edit Tab - Blur / Mosaic Tool
|
||||
* Whole-layer blur/mosaic buttons plus a drag-to-select rectangle mode
|
||||
* that applies blur or mosaic to just the dragged region.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
|
||||
if (w <= 0 || h <= 0 || size < 1) return;
|
||||
const imgData = ctx.getImageData(x, y, w, h);
|
||||
const d = imgData.data;
|
||||
for (let py = 0; py < h; py += size) {
|
||||
for (let px = 0; px < w; px += size) {
|
||||
const i = (py * w + px) * 4;
|
||||
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
|
||||
for (let by = py; by < Math.min(py + size, h); by++) {
|
||||
for (let bx = px; bx < Math.min(px + size, w); bx++) {
|
||||
const j = (by * w + bx) * 4;
|
||||
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, x, y);
|
||||
}
|
||||
|
||||
export class BlurTool {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => number} callbacks.getZoom
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.rectMode = null; // null | 'blur' | 'mosaic'
|
||||
this._dragging = false;
|
||||
this._dragStart = null;
|
||||
this._dragCur = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.rectMode = null;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.style.cursor = "";
|
||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const blurOn = this.rectMode === "blur";
|
||||
const mosaicOn = this.rectMode === "mosaic";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Whole:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Blur</label>
|
||||
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-blur-apply">Apply</button>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Mosaic</label>
|
||||
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
|
||||
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
|
||||
</div>
|
||||
<div style="width:1px;height:22px;background:var(--wfm-border);margin:0 6px;flex-shrink:0;"></div>
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Rect:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle"
|
||||
style="${blurOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Blur: ${blurOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle"
|
||||
style="${mosaicOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
|
||||
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
|
||||
});
|
||||
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "blur" ? null : "blur";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "mosaic" ? null : "mosaic";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
}
|
||||
|
||||
onMouseDown(pos) {
|
||||
if (!this.rectMode) return;
|
||||
this._dragging = true;
|
||||
this._dragStart = { x: pos.x, y: pos.y };
|
||||
this._dragCur = { x: pos.x, y: pos.y };
|
||||
}
|
||||
|
||||
onMouseMove(pos) {
|
||||
if (!this._dragging) return;
|
||||
this._dragCur = pos;
|
||||
this._drawPreview();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._applyRectEffect();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
applyWholeBlur(amount) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
applyWholeMosaic(size) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
|
||||
_canvasToLayerCoords(layer, cx, cy) {
|
||||
const centerX = layer.x + layer.displayW / 2;
|
||||
const centerY = layer.y + layer.displayH / 2;
|
||||
const dx = cx - centerX, dy = cy - centerY;
|
||||
const angle = -(layer.rotation || 0) * Math.PI / 180;
|
||||
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
|
||||
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
|
||||
const scaleX = layer.displayW / layer.canvas.width;
|
||||
const scaleY = layer.displayH / layer.canvas.height;
|
||||
let lx = rdx / scaleX + layer.canvas.width / 2;
|
||||
let ly = rdy / scaleY + layer.canvas.height / 2;
|
||||
if (layer.flipX) lx = layer.canvas.width - lx;
|
||||
if (layer.flipY) ly = layer.canvas.height - ly;
|
||||
return { x: lx, y: ly };
|
||||
}
|
||||
|
||||
_drawPreview() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (!overlay) return;
|
||||
const ctx = overlay.getContext("2d");
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (!s || !c) return;
|
||||
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
const zoom = this._cb.getZoom();
|
||||
ctx.strokeStyle = this.rectMode === "blur" ? "#4af" : "#fa4";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
ctx.setLineDash([4 / zoom, 2 / zoom]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
_applyRectEffect() {
|
||||
const layer = this._cb.getLayerManager()?.activeLayer;
|
||||
if (!layer || !this._dragStart || !this._dragCur) return;
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
|
||||
|
||||
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
|
||||
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
|
||||
const p1 = this._canvasToLayerCoords(layer, minX, minY);
|
||||
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
|
||||
|
||||
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
|
||||
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
|
||||
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
|
||||
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
|
||||
if (lw <= 0 || lh <= 0) return;
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (this.rectMode === "blur") {
|
||||
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
|
||||
} else {
|
||||
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
|
||||
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Image Edit Tab - File Export
|
||||
* Composites the visible layers (respecting mask-apply clipping) into a
|
||||
* flat canvas and offers three destinations: local PNG download, WFS
|
||||
* Gallery, and ComfyUI's own /upload/image endpoint.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { Layer } from "./LayerManager.js";
|
||||
|
||||
export class FileExport {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => {w: number, h: number}} callbacks.getCanvasSize
|
||||
* @param {() => string} callbacks.getBaseName
|
||||
* @param {(ctx: CanvasRenderingContext2D, drawCanvas: HTMLCanvasElement, maskLayer: object, targetLayer: object, showOverlay: boolean) => void} callbacks.renderMaskedLayer
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
}
|
||||
|
||||
_buildCompositeCanvas() {
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return canvas;
|
||||
this._compositeForExport(canvas, layerMgr);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
|
||||
_compositeForExport(target, layerMgr) {
|
||||
const ctx = target.getContext("2d");
|
||||
ctx.clearRect(0, 0, target.width, target.height);
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (!layer.visible) continue;
|
||||
if (maskedIndices.has(i)) continue;
|
||||
if (layer.type === "mask") {
|
||||
if (layer.maskApply && i + 1 < layers.length) {
|
||||
this._cb.renderMaskedLayer(ctx, target, layer, layers[i + 1], false);
|
||||
}
|
||||
// maskApply=false のマスクはエクスポートに含めない
|
||||
} else {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
ctx.globalCompositeOperation = layer.blendMode;
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savePng() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const a = document.createElement("a");
|
||||
a.href = canvas.toDataURL("image/png");
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + "-output.png";
|
||||
a.click();
|
||||
showToast("PNG saved", "success");
|
||||
}
|
||||
|
||||
async saveToGallery() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// デフォルトファイル名: wfs-image-YYYYMMDDHHmmss
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const defaultName = `wfs-image-${ts}`;
|
||||
|
||||
const filename = window.prompt("Save to Gallery — file name (without extension):", defaultName);
|
||||
if (filename === null) return; // キャンセル
|
||||
const safeName = filename.trim() || defaultName;
|
||||
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const imageData = canvas.toDataURL("image/png");
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: safeName, imageData }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
showToast(`Saved to Gallery: ${safeName}.png`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Gallery save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async uploadToComfyUI() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("overwrite", "true");
|
||||
try {
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
const data = await r.json();
|
||||
showToast(`Uploaded: ${data.name}`, "success");
|
||||
} catch {
|
||||
showToast("Upload failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the composite like uploadToComfyUI(), then (via window.opener,
|
||||
// since WFS runs in its own window — same cross-window pattern as
|
||||
// wfmReceiveWorkflow/wfmOpenMaskEditorForNode) writes the resulting
|
||||
// filename into the "image" widget of the currently selected ComfyUI
|
||||
// node (falling back to the first node in the graph with one), mirroring
|
||||
// the chat_TE custom node's "Send to workflow" button.
|
||||
async sendToWorkflow() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("overwrite", "true");
|
||||
try {
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
const data = await r.json();
|
||||
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
|
||||
window.opener.wfmSendImageToSelectedNode(filename);
|
||||
showToast(`Sent "${filename}" to workflow`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Send to workflow failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Image Edit Tab - G'MIC Integration
|
||||
* Launches the G'MIC GUI on the server for the active layer, polls job
|
||||
* status, and applies the resulting image back onto the layer.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class GmicIntegration {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.syncActiveLayerFromCanvas
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.state = {
|
||||
lastResultJobId: null,
|
||||
processing: false,
|
||||
aborted: false
|
||||
};
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const openBtnDisabled = this.state.processing ? "disabled" : "";
|
||||
const applyBtnDisabled = (!this.state.lastResultJobId || this.state.processing) ? "disabled" : "";
|
||||
const progressStyle = this.state.processing ? "display:flex" : "display:none";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-gmic-open-btn" ${openBtnDisabled}>G'MIC GUIで編集</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-apply-btn" ${applyBtnDisabled}>結果を反映</button>
|
||||
</div>
|
||||
<div class="ie-opt-group" id="ie-gmic-progress-area" style="${progressStyle}; align-items:center; gap:6px;">
|
||||
<span id="ie-gmic-progress-lbl" style="font-size:11px; color:var(--wfm-text-secondary);">G'MIC GUIを起動中...</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-abort-btn" style="background:#ea4335;color:#fff;">中断</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this.openGui());
|
||||
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this.applyResult());
|
||||
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this.abort());
|
||||
}
|
||||
|
||||
async openGui() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.syncActiveLayerFromCanvas();
|
||||
|
||||
if (this.state.processing) return;
|
||||
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
this.state.processing = true;
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
if (progressArea) progressArea.style.display = "flex";
|
||||
if (progressLbl) progressLbl.textContent = "画像をサーバーへ送信中...";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/gmic/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64: dataUrl })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
this.state.lastResultJobId = data.job_id;
|
||||
if (progressLbl) progressLbl.textContent = "G'MIC GUIで編集中... フィルターを選択して「OK」を押すと適用可能になります";
|
||||
await this._waitForJob(data.job_id);
|
||||
} catch (e) {
|
||||
if (e.message !== "__aborted__") {
|
||||
showToast("G'MIC Error: " + e.message, "error");
|
||||
}
|
||||
this.state.processing = false;
|
||||
this.state.aborted = false;
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.state.aborted = true;
|
||||
this.state.processing = false;
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
}
|
||||
|
||||
async _waitForJob(jobId) {
|
||||
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
const maxWait = 600, interval = 2000;
|
||||
const start = Date.now();
|
||||
this.state.aborted = false;
|
||||
|
||||
while (true) {
|
||||
if (this.state.aborted) throw new Error("__aborted__");
|
||||
if ((Date.now() - start) / 1000 > maxWait) throw new Error("Timeout");
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
if (this.state.aborted) throw new Error("__aborted__");
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/gmic/status/${jobId}`);
|
||||
if (res.status === 404) throw new Error("__aborted__");
|
||||
if (!res.ok) continue;
|
||||
const status = await res.json();
|
||||
if (status.status === "completed") {
|
||||
this.state.lastResultJobId = jobId;
|
||||
if (applyBtn) applyBtn.disabled = false;
|
||||
if (progressLbl) progressLbl.textContent = "処理完了 → 「結果を反映」で画像に適用";
|
||||
this.state.processing = false;
|
||||
showToast("G'MIC filtering complete. Click Apply to insert result.", "success");
|
||||
return;
|
||||
}
|
||||
if (status.status === "failed") {
|
||||
if (progressLbl) progressLbl.textContent = status.error || "G'MIC GUIがキャンセルされました";
|
||||
throw new Error("__aborted__");
|
||||
}
|
||||
if (progressLbl) progressLbl.textContent = status.message || "G'MIC GUIで編集中...";
|
||||
} catch (e) {
|
||||
if (e.message === "__aborted__" || e.message.includes("Timeout")) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyResult() {
|
||||
if (!this.state.lastResultJobId) {
|
||||
showToast("No G'MIC result to apply", "error");
|
||||
return;
|
||||
}
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`/api/wfm/gmic/status/${this.state.lastResultJobId}`);
|
||||
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
|
||||
const statusData = await statusRes.json();
|
||||
if (!statusData.result_path) throw new Error("No result path found");
|
||||
|
||||
const b64res = await fetch("/api/wfm/gmic/result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result_path: statusData.result_path })
|
||||
});
|
||||
if (!b64res.ok) throw new Error(`HTTP ${b64res.status}`);
|
||||
const { image_b64: dataUrl } = await b64res.json();
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Failed to load result image"));
|
||||
i.src = dataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const layer = layerMgr?.activeLayer;
|
||||
if (!layer) throw new Error("No active layer");
|
||||
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast("G'MIC filter applied successfully", "success");
|
||||
|
||||
// Reset G'MIC status
|
||||
this.state.lastResultJobId = null;
|
||||
this.state.processing = false;
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
this._cb.renderToolOptions("filter");
|
||||
} catch (err) {
|
||||
showToast("Failed to apply G'MIC result: " + err.message, "error");
|
||||
if (applyBtn) applyBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Image Edit Tab - Inpaint / I2I Actions
|
||||
* Composites the active mask layer against the background and runs it
|
||||
* through GenerateUI's currently loaded workflow (or a dedicated one) for
|
||||
* inpainting, plus a mask-less I2I variant. Also exposes the external
|
||||
* entry points used by other same-origin panels (e.g. Comic Creator).
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { comfyUI } from "../comfyui-client.js";
|
||||
import { comfyEditor } from "../comfyui-editor.js";
|
||||
import { comfyWorkflow } from "../comfyui-workflow.js";
|
||||
|
||||
export class InpaintI2IActions {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
|
||||
this.positive = "";
|
||||
this.negative = "";
|
||||
this.growMaskBy = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this.denoise = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this.running = false;
|
||||
this.resultUrl = null;
|
||||
this.i2iExternalRunning = false;
|
||||
|
||||
// Inpaint 専用ワークフロー(OFF時は GenerateUI で読み込み中のワークフローを使用)
|
||||
this.useDedicated = false;
|
||||
this.dedicatedFilename = null;
|
||||
this.workflowList = [];
|
||||
}
|
||||
|
||||
async fetchWorkflowList(onLoaded) {
|
||||
try {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows`);
|
||||
if (!resp.ok) return;
|
||||
const list = await resp.json();
|
||||
this.workflowList = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
onLoaded?.();
|
||||
} catch {
|
||||
this.workflowList = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
const body = document.getElementById("ie-props-body");
|
||||
const title = document.getElementById("ie-props-title");
|
||||
if (!pane || !body) return;
|
||||
pane.style.display = "flex";
|
||||
if (title) title.textContent = "Inpaint";
|
||||
|
||||
// 未初期化ならワークフローの現在値から初期化(VAEEncodeForInpaint.grow_mask_by / KSampler.denoise)
|
||||
if (this.growMaskBy == null) {
|
||||
this.growMaskBy = comfyUI.currentAnalysis?.inpaint_encode_nodes?.[0]?.grow_mask_by ?? 6;
|
||||
}
|
||||
if (this.denoise == null) {
|
||||
const samplerNode = (comfyUI.currentAnalysis?.sampler_nodes || []).find((n) => n.denoise !== undefined);
|
||||
this.denoise = samplerNode?.denoise ?? 1.0;
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-inpaint-use-dedicated" ${this.useDedicated ? "checked" : ""}>
|
||||
Use dedicated workflow
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-props-row" id="ie-inpaint-dedicated-row" style="${this.useDedicated ? "" : "display:none;"}">
|
||||
<select id="ie-inpaint-dedicated-select" style="flex:1;">
|
||||
<option value="">-- select workflow --</option>
|
||||
${this.workflowList.map((f) => `<option value="${f}" ${f === this.dedicatedFilename ? "selected" : ""}>${f}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.positive}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.negative}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Grow Mask By</label>
|
||||
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this.growMaskBy}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Denoise</label>
|
||||
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this.denoise}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this.running ? "disabled" : ""}>
|
||||
${this.running ? "Running..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-inpaint-status" style="font-size:11px;color:var(--wfm-text-secondary);"></span>
|
||||
${this.resultUrl ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Result</label>
|
||||
<img src="${this.resultUrl}" style="max-width:100%;border-radius:var(--wfm-radius-sm);border:1px solid var(--wfm-border);">
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
|
||||
document.getElementById("ie-inpaint-use-dedicated")?.addEventListener("change", e => {
|
||||
this.useDedicated = e.target.checked;
|
||||
this.renderPanel();
|
||||
});
|
||||
document.getElementById("ie-inpaint-dedicated-select")?.addEventListener("change", e => {
|
||||
this.dedicatedFilename = e.target.value || null;
|
||||
});
|
||||
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this.positive = e.target.value; });
|
||||
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this.negative = e.target.value; });
|
||||
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
|
||||
this.growMaskBy = parseInt(e.target.value) || 0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
|
||||
this.denoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
|
||||
if (Number.isNaN(this.denoise)) this.denoise = 1.0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this.run());
|
||||
}
|
||||
|
||||
_exportMaskCanvas(maskLayer) {
|
||||
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(maskLayer.canvas, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.running) return;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
|
||||
const maskLayer = (layerMgr.activeLayer?.type === "mask" && layerMgr.activeLayer.visible)
|
||||
? layerMgr.activeLayer
|
||||
: layerMgr.layers.find(l => l.type === "mask" && l.visible);
|
||||
if (!maskLayer) { showToast("Create or show a mask layer first (Mask tool)", "info"); return; }
|
||||
|
||||
if (this.useDedicated) {
|
||||
if (!this.dedicatedFilename) { showToast("Select a dedicated workflow first", "info"); return; }
|
||||
} else if (!comfyUI.currentAnalysis) {
|
||||
showToast("Load a workflow in GenerateUI first", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const runBtn = document.getElementById("ie-inpaint-run-btn");
|
||||
const statusEl = document.getElementById("ie-inpaint-status");
|
||||
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
this.running = true;
|
||||
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
|
||||
setStatus(this.useDedicated ? "Loading dedicated workflow..." : "Compositing...");
|
||||
|
||||
try {
|
||||
// 専用ワークフロー使用時は GenerateUI の comfyUI.currentWorkflow/currentAnalysis には
|
||||
// 一切触れず、ここでロード・解析したローカルなワークフロー/解析結果だけを使う。
|
||||
let overrideOpts = {};
|
||||
|
||||
if (this.useDedicated) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(this.dedicatedFilename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let dedicatedWorkflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(dedicatedWorkflow, this.dedicatedFilename);
|
||||
if (format === "ui") {
|
||||
dedicatedWorkflow = await comfyWorkflow.convertUiToApi(dedicatedWorkflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
overrideOpts = { workflow: dedicatedWorkflow, analysis: comfyWorkflow.analyzeWorkflow(dedicatedWorkflow) };
|
||||
}
|
||||
|
||||
setStatus("Compositing...");
|
||||
const bgCanvas = this._cb.buildBgCanvas();
|
||||
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
|
||||
const maskCanvas = this._exportMaskCanvas(maskLayer);
|
||||
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
|
||||
|
||||
const resultUrl = await this._runWithImages(imageBlob, maskBlob, {
|
||||
positive: this.positive,
|
||||
negative: this.negative,
|
||||
growMaskBy: this.growMaskBy,
|
||||
denoise: this.denoise,
|
||||
overrideOpts,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
if (resultUrl) this.resultUrl = resultUrl;
|
||||
|
||||
setStatus("Done");
|
||||
showToast("Inpaint generation complete", "success");
|
||||
} catch (err) {
|
||||
setStatus("Error");
|
||||
showToast(`Inpaint failed: ${err.message}`, "error");
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.renderPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像+マスクBlobを対象ノード(LoadImage/MaskEditorOne)へ反映し、
|
||||
// プロンプト/grow_mask_by/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// UI駆動の run() と外部(Comic Creator等)向けの runExternal() の両方から使う。
|
||||
async _runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
// LoadImage(の MASK 出力が使われている)スロットを優先し、無ければ Mask Editor One
|
||||
// ノード(LoadImage を介さず画像/マスクを内包する custom node)にフォールバックする
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
const slotIndex = (analysisForLookup.load_image_nodes || []).findIndex(n => n.mask_used);
|
||||
const meoNode = (analysisForLookup.mask_editor_one_nodes || [])[0];
|
||||
if (slotIndex === -1 && !meoNode) {
|
||||
throw new Error("No inpaint-capable LoadImage or Mask Editor One node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
if (slotIndex !== -1) {
|
||||
await comfyEditor.applyImageAndMaskToSlot(imageBlob, maskBlob, slotIndex, overrideOpts);
|
||||
} else {
|
||||
await comfyEditor.applyImageAndMaskToMaskEditorOneNode(imageBlob, maskBlob, meoNode.id, overrideOpts);
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ growMaskBy, denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント。
|
||||
// 専用ワークフロー選択(useDedicated)は扱わず、常にGenerate UIに現在
|
||||
// ロード中のワークフロー(comfyUI.currentWorkflow/currentAnalysis)を対象にする。
|
||||
async runExternal(imageBlob, maskBlob, { positive = "", negative = "", growMaskBy = 6, denoise = 1.0 } = {}) {
|
||||
if (this.running) throw new Error("Inpaint is already running");
|
||||
this.running = true;
|
||||
try {
|
||||
const resultUrl = await this._runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像Blob(マスクなし)を対象LoadImageノード(slot 0)へ反映し、
|
||||
// プロンプト/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// _runWithImages のマスクなし版(Comic CreatorのSelect I2I連携専用)。
|
||||
async _runI2IWithImage(imageBlob, { positive, negative, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
if (!(analysisForLookup.load_image_nodes?.length > 0)) {
|
||||
throw new Error("No LoadImage node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
const file = new File([imageBlob], `i2i_${Math.random().toString(36).slice(2, 10)}.png`, { type: "image/png" });
|
||||
await comfyEditor.applyImageToSlot(file, 0, overrideOpts);
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント(I2I版)。
|
||||
// runExternal と対になる、マスク不要のシンプルなI2I実行。
|
||||
async runI2IExternal(imageBlob, { positive = "", negative = "", denoise = 1.0 } = {}) {
|
||||
if (this.i2iExternalRunning) throw new Error("I2I is already running");
|
||||
this.i2iExternalRunning = true;
|
||||
try {
|
||||
const resultUrl = await this._runI2IWithImage(imageBlob, { positive, negative, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this.i2iExternalRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Editor One 連携ブリッジ
|
||||
* WFS Image Edit タブの合成画像を、ComfyUIキャンバス上のMaskEditorOneノード(選択中のノードを
|
||||
* 優先、無ければグラフ内で最初に見つかったもの)へ送り、window.opener(ComfyUIタブ)側で実際の
|
||||
* モーダルを開いて編集させ、Apply後の結果マスクを新規マスクレイヤーとしてWFS側に取り込む。
|
||||
* 「Send to Workflow」の image ウィジェット探索と同じ「選択中優先・フォールバック」方式のため、
|
||||
* WFS側のワークフロー読み込み状況とは無関係に動作する。
|
||||
* モーダル自体は ComfyUI ページの LiteGraph ノードに強く依存するため、WFS 側からは
|
||||
* window.opener.wfmOpenMaskEditorForNode()(web/comfyui/node_sets_menu.js 側で定義)を介して操作する。
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class MaskEditorOneBridge {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
get bridgeReady() {
|
||||
return !!(window.opener && typeof window.opener.wfmOpenMaskEditorForNode === "function");
|
||||
}
|
||||
|
||||
async openEditor() {
|
||||
if (this.running) return;
|
||||
if (!this.bridgeReady) {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
const dataUrl = this._cb.buildBgCanvas().toDataURL("image/png");
|
||||
// アクティブレイヤーが既存のマスクレイヤーなら、その内容もそのまま Mask Editor One の
|
||||
// 初期マスクとして渡す(画像だけでなく、今描いているマスクも引き継ぐ)
|
||||
const activeLayer = layerMgr.activeLayer;
|
||||
const existingMaskDataUrl = activeLayer?.type === "mask" ? activeLayer.canvas.toDataURL("image/png") : null;
|
||||
window.opener.focus();
|
||||
showToast("Switch to the ComfyUI tab, edit the mask, then click Apply", "info");
|
||||
|
||||
const maskDataUrl = await window.opener.wfmOpenMaskEditorForNode(dataUrl, existingMaskDataUrl);
|
||||
if (!maskDataUrl) {
|
||||
showToast("Mask Editor One: cancelled (no changes)", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
this._cb.saveUndo();
|
||||
const maskLayer = layerMgr.addLayer("mask", "Mask Editor One", {
|
||||
contentW: layerMgr.width, contentH: layerMgr.height,
|
||||
displayW: layerMgr.width, displayH: layerMgr.height,
|
||||
x: 0, y: 0,
|
||||
});
|
||||
layerMgr.setActive(maskLayer.id);
|
||||
await this._applyMask(maskLayer, maskDataUrl);
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast("Mask Editor One: mask imported", "success");
|
||||
} catch (err) {
|
||||
showToast("Mask Editor One error: " + err.message, "error");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// グレースケール輝度 → アルファ白マスクに変換して書き込む(Sam3Segmentation._applyMask と同じ変換)
|
||||
_applyMask(maskLayer, maskDataUrl) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const W = maskLayer.canvas.width;
|
||||
const H = maskLayer.canvas.height;
|
||||
const off = document.createElement("canvas");
|
||||
off.width = W; off.height = H;
|
||||
const mc = off.getContext("2d");
|
||||
mc.drawImage(img, 0, 0, W, H);
|
||||
const imgData = mc.getImageData(0, 0, W, H);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const lum = d[i];
|
||||
d[i] = d[i + 1] = d[i + 2] = 255;
|
||||
d[i + 3] = lum;
|
||||
}
|
||||
mc.putImageData(imgData, 0, 0);
|
||||
maskLayer.ctx.drawImage(off, 0, 0);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = resolve;
|
||||
img.src = maskDataUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Image Edit Tab - SAM3 Segmentation (Mask Editor One integration)
|
||||
* Text-prompted segmentation via the Mask Editor One SAM3 server endpoints.
|
||||
* Rendered as a subtool of the Mask tool; the toolbar shell itself stays in
|
||||
* ImageEditTab (shared with the other mask subtools), but the prompt/run
|
||||
* controls and the result thumbnail grid are owned here.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class Sam3Segmentation {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
* @param {(sub: string) => void} callbacks.renderMaskProps
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.available = false;
|
||||
this.results = []; // [{mask_b64, score, area}, ...]
|
||||
this.prompt = "";
|
||||
this.maxMasks = 9;
|
||||
this.loading = false;
|
||||
this.mode = "add"; // "add" | "erase"
|
||||
this.selected = new Set(); // 選択中の結果インデックス
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/sam3/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.available = json.loaded === true || json.ckpt_found === true;
|
||||
} catch {
|
||||
this.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
// マスクツールバー内、SAM3選択時にのみ表示するprompt/max/runコントロール
|
||||
renderToolbarExtra(activeSub) {
|
||||
if (!this.available || activeSub !== "sam3") return "";
|
||||
return `
|
||||
<div class="ie-opt-group">
|
||||
<input type="text" id="ie-sam3-prompt" class="ie-opt-input"
|
||||
placeholder="e.g. cat, person..."
|
||||
value="${this.prompt}"
|
||||
style="width:160px;font-size:11px;padding:2px 6px;border:1px solid var(--wfm-border);border-radius:3px;background:var(--wfm-surface);color:var(--wfm-text);">
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;color:var(--wfm-text-secondary);">Max</label>
|
||||
<select id="ie-sam3-max" class="ie-opt-select" style="width:44px;">
|
||||
${[3,6,9,12].map(n => `<option value="${n}"${n === this.maxMasks ? " selected" : ""}>${n}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-run-btn" ${this.loading ? "disabled" : ""}>
|
||||
${this.loading ? "Running..." : "Segment"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-sam3-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;">
|
||||
${this.results.length > 0 ? `${this.results.length} masks found` : ""}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
bindToolbarEvents() {
|
||||
document.getElementById("ie-sam3-prompt")?.addEventListener("input", e => {
|
||||
this.prompt = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-sam3-max")?.addEventListener("change", e => {
|
||||
this.maxMasks = parseInt(e.target.value);
|
||||
});
|
||||
document.getElementById("ie-sam3-run-btn")?.addEventListener("click", () => this.runSegment());
|
||||
}
|
||||
|
||||
// props ペイン("sam3" サブツール選択時): モード切替 + 結果サムネイルグリッド
|
||||
renderResultsPanel(body) {
|
||||
const modeAdd = this.mode === "add";
|
||||
const selCount = this.selected.size;
|
||||
const hasResult = this.results.length > 0;
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-add" style="flex:1;">Add</button>
|
||||
<button class="wfm-btn wfm-btn-sm${!modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-erase" style="flex:1;">Erase</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasResult ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);">Click to select / deselect:</div>
|
||||
<div id="ie-sam3-results" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
|
||||
${this.results.map((r, i) => {
|
||||
const sel = this.selected.has(i);
|
||||
const borderColor = sel ? "var(--wfm-primary,#4682e6)" : "var(--wfm-border)";
|
||||
const bg = sel ? "color-mix(in srgb,var(--wfm-primary,#4682e6) 15%,transparent)" : "transparent";
|
||||
return `<div class="ie-sam3-thumb" data-idx="${i}"
|
||||
style="cursor:pointer;border:2px solid ${borderColor};border-radius:4px;overflow:hidden;text-align:center;background:${bg};position:relative;">
|
||||
<img src="${r.mask_b64}" style="width:100%;display:block;background:#000;">
|
||||
<div style="font-size:10px;padding:2px 0;color:var(--wfm-text-secondary);">
|
||||
${Math.round((r.score ?? 0) * 100)}%
|
||||
</div>
|
||||
${sel ? '<div style="position:absolute;top:2px;right:3px;font-size:12px;line-height:1;color:var(--wfm-primary,#4682e6);">✓</div>' : ""}
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-apply-btn"
|
||||
${selCount === 0 ? "disabled" : ""} style="margin-top:4px;">
|
||||
Apply Selected${selCount > 0 ? ` (${selCount})` : ""}
|
||||
</button>
|
||||
</div>` : `
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);padding:4px 0;">
|
||||
${this.loading ? "Segmenting..." : "Enter a prompt and press Segment"}
|
||||
</div>`}
|
||||
`;
|
||||
document.getElementById("ie-sam3-mode-add")?.addEventListener("click", () => {
|
||||
this.mode = "add";
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
document.getElementById("ie-sam3-mode-erase")?.addEventListener("click", () => {
|
||||
this.mode = "erase";
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
body.querySelectorAll(".ie-sam3-thumb").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const idx = parseInt(el.dataset.idx);
|
||||
if (this.selected.has(idx)) this.selected.delete(idx);
|
||||
else this.selected.add(idx);
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
});
|
||||
document.getElementById("ie-sam3-apply-btn")?.addEventListener("click", () => {
|
||||
this.applySelectedMasks();
|
||||
});
|
||||
}
|
||||
|
||||
async runSegment() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr || this.loading) return;
|
||||
const prompt = this.prompt.trim();
|
||||
if (!prompt) { showToast("Please enter a prompt", "warning"); return; }
|
||||
|
||||
// 推論対象: アクティブレイヤー(マスクなら下のイメージレイヤーを探す)
|
||||
let imageLayer = layerMgr.activeLayer;
|
||||
if (!imageLayer || imageLayer.type === "mask") {
|
||||
imageLayer = layerMgr.layers.find(l => l.type !== "mask" && l.visible);
|
||||
}
|
||||
if (!imageLayer) { showToast("No image layer found", "error"); return; }
|
||||
|
||||
const NODE_ID = "wfs_sam3";
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
this.selected = new Set();
|
||||
this._cb.renderToolOptions("mask");
|
||||
this._cb.renderMaskProps("sam3");
|
||||
|
||||
try {
|
||||
const dataUrl = imageLayer.canvas.toDataURL("image/png");
|
||||
await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
|
||||
const resp = await fetch("/mask_editor/sam3/segment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, prompt, max_masks: this.maxMasks }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
this.results = json.masks || [];
|
||||
if (this.results.length === 0) showToast("No masks found", "warning");
|
||||
else showToast(`${this.results.length} mask(s) found`, "success");
|
||||
} catch (err) {
|
||||
showToast("SAM3 error: " + err.message, "error");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._cb.renderToolOptions("mask");
|
||||
this._cb.renderMaskProps("sam3");
|
||||
}
|
||||
}
|
||||
|
||||
async applySelectedMasks() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr || this.selected.size === 0) return;
|
||||
const indices = [...this.selected].sort((a, b) => a - b);
|
||||
const masks = indices.map(i => this.results[i]).filter(Boolean);
|
||||
if (masks.length === 0) return;
|
||||
|
||||
let maskLayer = layerMgr.activeLayer;
|
||||
if (!maskLayer || maskLayer.type !== "mask") {
|
||||
const ref = layerMgr.activeLayer;
|
||||
maskLayer = layerMgr.addLayer("mask", "SAM3 Mask", {
|
||||
contentW: ref?.canvas.width ?? layerMgr.width,
|
||||
contentH: ref?.canvas.height ?? layerMgr.height,
|
||||
displayW: ref?.displayW ?? layerMgr.width,
|
||||
displayH: ref?.displayH ?? layerMgr.height,
|
||||
x: ref?.x ?? 0, y: ref?.y ?? 0,
|
||||
});
|
||||
layerMgr.setActive(maskLayer.id);
|
||||
}
|
||||
|
||||
this._cb.saveUndo();
|
||||
for (const r of masks) {
|
||||
await this._applyMask(maskLayer, r.mask_b64, this.mode);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast(`SAM3: ${masks.length} mask(s) applied (${this.mode})`, "success");
|
||||
}
|
||||
|
||||
_applyMask(maskLayer, maskB64, mode = "add") {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const W = maskLayer.canvas.width;
|
||||
const H = maskLayer.canvas.height;
|
||||
// グレースケール輝度 → アルファ白マスクに変換
|
||||
const off = document.createElement("canvas");
|
||||
off.width = W; off.height = H;
|
||||
const mc = off.getContext("2d");
|
||||
mc.drawImage(img, 0, 0, W, H);
|
||||
const imgData = mc.getImageData(0, 0, W, H);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const lum = d[i];
|
||||
d[i] = d[i+1] = d[i+2] = 255;
|
||||
d[i+3] = lum;
|
||||
}
|
||||
mc.putImageData(imgData, 0, 0);
|
||||
// モードに合わせてマスクキャンバスに合成
|
||||
maskLayer.ctx.save();
|
||||
maskLayer.ctx.globalCompositeOperation = mode === "erase" ? "destination-out" : "source-over";
|
||||
maskLayer.ctx.drawImage(off, 0, 0);
|
||||
maskLayer.ctx.restore();
|
||||
resolve();
|
||||
};
|
||||
img.onerror = resolve;
|
||||
img.src = maskB64;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* ImagePrompt Gallery — Gallery タブのサブタブ。
|
||||
* ws_image_prompt フォルダ配下の画像+.txtサイドカーを閲覧し、
|
||||
* クリックで選択→「追加」でプロンプトを組み立てて最終的にコピーする。
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
|
||||
const API = {
|
||||
root: () => `/wfm/gallery/image-prompt/root`,
|
||||
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
|
||||
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
|
||||
imageMeta: (path) => `/wfm/gallery/image/meta?path=${encodeURIComponent(path)}`,
|
||||
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
savePrompt: `/wfm/gallery/image/image-prompt`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
root: null,
|
||||
currentFolder: null,
|
||||
search: "",
|
||||
images: [],
|
||||
selectedImage: null,
|
||||
chips: [], // [{ path, filename, prompt }]
|
||||
};
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────────────────────
|
||||
|
||||
export function initImagePromptTab() {
|
||||
document.getElementById("wfm-imageprompt-refresh-btn")?.addEventListener("click", () => {
|
||||
loadFolderTree();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-imageprompt-search", "wfm-imageprompt-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById("wfm-imageprompt-search");
|
||||
searchInput?.addEventListener("input", debounce(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadImages();
|
||||
}, 300));
|
||||
|
||||
document.getElementById("wfm-imageprompt-add-btn")?.addEventListener("click", addSelectedToChips);
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn")?.addEventListener("click", saveSelectedPrompt);
|
||||
document.getElementById("wfm-imageprompt-clear-btn")?.addEventListener("click", clearAllChips);
|
||||
document.getElementById("wfm-imageprompt-copy-btn")?.addEventListener("click", copyFinalPrompt);
|
||||
}
|
||||
|
||||
export async function activateImagePromptTab() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
const data = await apiFetch(API.root());
|
||||
state.root = data.root;
|
||||
await loadFolderTree();
|
||||
} catch (e) {
|
||||
const tree = document.getElementById("wfm-imageprompt-tree");
|
||||
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
async function loadFolderTree() {
|
||||
if (!state.root) return;
|
||||
const tree = document.getElementById("wfm-imageprompt-tree");
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
try {
|
||||
const data = await apiFetch(API.folders(state.root));
|
||||
if (data.error) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
|
||||
return;
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTreeNode(node, container, depth, isRoot) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-gallery-tree-item";
|
||||
item.style.paddingLeft = `${depth * 12}px`;
|
||||
item.dataset.path = node.abs_path;
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const absPath = node.abs_path;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wfm-gallery-tree-arrow";
|
||||
if (hasChildren) {
|
||||
arrow.textContent = "▶";
|
||||
arrow.dataset.expanded = "false";
|
||||
} else {
|
||||
arrow.textContent = " ";
|
||||
arrow.style.visibility = "hidden";
|
||||
}
|
||||
item.appendChild(arrow);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
// サブフォルダ内も含めた合計件数(クリックで再帰的に全画像を表示するため)
|
||||
const totalCount = node.image_count_total ?? node.image_count;
|
||||
if (totalCount > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
badge.textContent = totalCount;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
item.appendChild(label);
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
document.querySelectorAll("#wfm-imageprompt-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
|
||||
item.classList.add("selected");
|
||||
state.currentFolder = absPath;
|
||||
loadImages();
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "wfm-gallery-tree-children";
|
||||
childContainer.style.display = "none";
|
||||
|
||||
arrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const expanded = arrow.dataset.expanded === "true";
|
||||
if (!expanded) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
if (childContainer.children.length === 0) {
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
arrow.dataset.expanded = "false";
|
||||
arrow.textContent = "▶";
|
||||
childContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画像一覧 ─────────────────────────────────────────────────
|
||||
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
const grid = document.getElementById("wfm-imageprompt-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
|
||||
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
|
||||
if (state.search) params.search = state.search;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(API.images(params));
|
||||
state.images = data.images || [];
|
||||
document.getElementById("wfm-imageprompt-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const grid = document.getElementById("wfm-imageprompt-grid");
|
||||
if (state.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createThumbCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-gallery-thumb-card";
|
||||
card.title = img.filename;
|
||||
card.dataset.path = img.path;
|
||||
if (state.selectedImage && state.selectedImage.path === img.path) {
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "wfm-gallery-thumb-img";
|
||||
imgEl.loading = "lazy";
|
||||
imgEl.src = API.thumb(img.path);
|
||||
imgEl.alt = img.filename;
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
card.addEventListener("click", () => selectImage(img));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 選択画像 / プロンプト表示 ─────────────────────────────────
|
||||
|
||||
async function selectImage(img) {
|
||||
state.selectedImage = img;
|
||||
document.querySelectorAll("#wfm-imageprompt-grid .wfm-gallery-thumb-card").forEach(el => {
|
||||
el.classList.toggle("selected", el.dataset.path === img.path);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-imageprompt-selected-name").textContent = img.filename;
|
||||
const preview = document.getElementById("wfm-imageprompt-selected-preview");
|
||||
preview.innerHTML = "";
|
||||
const previewImg = document.createElement("img");
|
||||
previewImg.src = API.serveImage(img.path);
|
||||
previewImg.alt = img.filename;
|
||||
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;";
|
||||
preview.appendChild(previewImg);
|
||||
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
promptBox.value = "";
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = true;
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = true;
|
||||
|
||||
try {
|
||||
const meta = await apiFetch(API.imageMeta(img.path));
|
||||
promptBox.value = meta.image_prompt || "";
|
||||
document.getElementById("wfm-imageprompt-save-prompt-btn").disabled = false;
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = !meta.image_prompt;
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedPrompt() {
|
||||
if (!state.selectedImage) return;
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
try {
|
||||
const res = await apiFetch(API.savePrompt, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: state.selectedImage.path, prompt: promptBox.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(t("imagePromptSaved"), "success");
|
||||
document.getElementById("wfm-imageprompt-add-btn").disabled = !promptBox.value.trim();
|
||||
} else {
|
||||
showToast(t("saveFailed"), "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── プロンプトビルダー (チップ / 最終プロンプト) ─────────────────
|
||||
|
||||
function cleanPromptText(text) {
|
||||
return String(text || "")
|
||||
.replace(/^[,\s]+|[,\s]+$/g, "")
|
||||
.replace(/\s*,\s*/g, ", ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function addSelectedToChips() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
const promptBox = document.getElementById("wfm-imageprompt-selected-prompt");
|
||||
const prompt = cleanPromptText(promptBox.value);
|
||||
if (!prompt) return;
|
||||
if (state.chips.some(c => c.path === img.path)) {
|
||||
showToast(t("imagePromptAlreadyAdded"), "info");
|
||||
return;
|
||||
}
|
||||
state.chips.push({ path: img.path, filename: img.filename, prompt });
|
||||
renderChips();
|
||||
rebuildFinalPrompt();
|
||||
}
|
||||
|
||||
function removeChip(path) {
|
||||
state.chips = state.chips.filter(c => c.path !== path);
|
||||
renderChips();
|
||||
rebuildFinalPrompt();
|
||||
}
|
||||
|
||||
function clearAllChips() {
|
||||
state.chips = [];
|
||||
renderChips();
|
||||
document.getElementById("wfm-imageprompt-final-prompt").value = "";
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const container = document.getElementById("wfm-imageprompt-chips");
|
||||
container.innerHTML = "";
|
||||
state.chips.forEach(chip => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "wfm-imageprompt-chip";
|
||||
el.title = chip.prompt;
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "wfm-imageprompt-chip-text";
|
||||
text.textContent = chip.filename.replace(/\.[^.]+$/, "");
|
||||
el.appendChild(text);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "wfm-imageprompt-chip-remove";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => removeChip(chip.path));
|
||||
el.appendChild(removeBtn);
|
||||
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildFinalPrompt() {
|
||||
const combined = state.chips.map(c => c.prompt).join(", ");
|
||||
document.getElementById("wfm-imageprompt-final-prompt").value = cleanPromptText(combined);
|
||||
}
|
||||
|
||||
async function copyFinalPrompt() {
|
||||
const text = document.getElementById("wfm-imageprompt-final-prompt").value;
|
||||
if (!text.trim()) {
|
||||
showToast(t("imagePromptNothingToCopy"), "info");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(t("imagePromptCopiedToast"), "success");
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+207
-38
@@ -6,6 +6,7 @@
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
// ── File size limit ───────────────────────────────────────────
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
@@ -74,7 +75,7 @@ function parseITXtChunk(data, latin1, utf8) {
|
||||
pos = findNull(data, pos); if (pos === -1) return null; pos++;
|
||||
return { keyword, text: utf8.decode(data.slice(pos)) };
|
||||
}
|
||||
async function readAllPNGTextChunks(file) {
|
||||
export async function readAllPNGTextChunks(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
@@ -187,11 +188,67 @@ function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("
|
||||
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
|
||||
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
|
||||
|
||||
// CLIPTextEncodeEditPlus (model-and-prompt-from-metadata) の encode() と同じ結合ルール。
|
||||
// RAW: text1のみ。EDIT: text_editのみ。front/back: text2(未接続ならtext_edit)をtext1の前/後に結合。
|
||||
function resolveEditPlusText(mode, textEdit, text1, text2) {
|
||||
const t1 = typeof text1 === "string" ? text1 : "";
|
||||
const edit = typeof textEdit === "string" ? textEdit : "";
|
||||
const insert = typeof text2 === "string" && text2 !== "" ? text2 : edit;
|
||||
switch (mode) {
|
||||
case "RAW": return t1;
|
||||
case "EDIT": return edit;
|
||||
case "front": return t1 ? `${insert}, ${t1}` : insert;
|
||||
case "back": return t1 ? `${t1}, ${insert}` : insert;
|
||||
default: return t1 || edit;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPrompts(wf) {
|
||||
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
|
||||
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
|
||||
}
|
||||
|
||||
// LiteGraph形式: リンクを辿ってテキストを解決(ComfySwitchNode等の中継ノードにも対応)
|
||||
function resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const srcNode = nodeMap.get(srcId);
|
||||
if (!srcNode) return null;
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[slot];
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// ComfySwitchNode ("If/Else Switch") — 片方(TextGenerateなどLLMノード)は静的解決不能なため
|
||||
// on_false/on_true の両方を試し、リテラルへ解決できた方を採用する。
|
||||
if (srcType === "ComfySwitchNode" && Array.isArray(srcNode.inputs)) {
|
||||
for (const name of ["on_false", "on_true"]) {
|
||||
const inp = srcNode.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) continue;
|
||||
const text = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// WFS_PromptText, PrimitiveStringMultiline 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[slot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") return v;
|
||||
// さらにリンクされている場合(StringReplace等の中継ノード)は追跡する
|
||||
// "source" = PreviewAny(値タップ中継), "string_a" = StringConcatenate(LoRAトリガーワード
|
||||
// 連結等)— Krea-2のプロンプト強化配線で使われる中継ノード
|
||||
if (Array.isArray(srcNode.inputs)) {
|
||||
const nextInput = srcNode.inputs.find(i => ["text", "value", "prompt", "string", "source", "string_a"].includes(i.name));
|
||||
if (nextInput?.link != null) {
|
||||
const originId = linkOrigin.get(nextInput.link);
|
||||
const originSlot = linkSlot.get(nextInput.link) ?? 0;
|
||||
if (originId != null) return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot, depth + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
|
||||
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
|
||||
function extractPromptsFromNodeSet(nodes, links) {
|
||||
@@ -211,7 +268,22 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const n of nodes) {
|
||||
if (!isTextEncoderNode(n.type ?? "")) continue;
|
||||
const type = n.type ?? "";
|
||||
if (type === "CLIPTextEncodeEditPlus") {
|
||||
const resolveNamedInput = (name) => {
|
||||
if (!Array.isArray(n.inputs)) return null;
|
||||
const inp = n.inputs.find(i => i.name === name);
|
||||
if (inp?.link == null) return null;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
const originSlot = linkSlot.get(inp.link) ?? 0;
|
||||
if (originId == null) return null;
|
||||
return resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
};
|
||||
const combined = resolveEditPlusText(n.widgets_values?.[1], n.widgets_values?.[0], resolveNamedInput("text1"), resolveNamedInput("text2"));
|
||||
if (combined) textMap.set(n.id, combined);
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(type)) continue;
|
||||
const text = n.widgets_values?.[0];
|
||||
if (text && typeof text === "string") {
|
||||
textMap.set(n.id, text);
|
||||
@@ -220,18 +292,8 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
if (textInput?.link != null) {
|
||||
const originId = linkOrigin.get(textInput.link);
|
||||
const originSlot = linkSlot.get(textInput.link) ?? 0;
|
||||
const srcNode = originId != null ? nodeMap.get(originId) : null;
|
||||
if (srcNode) {
|
||||
const srcType = srcNode.type ?? "";
|
||||
if (isPromptStylerNode(srcType)) {
|
||||
const v = srcNode.widgets_values?.[originSlot];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
} else {
|
||||
// WFS_PromptText, PrimitiveStringMultiline, ComfySwitchNode 等、任意の STRING 出力ノード
|
||||
const v = srcNode.widgets_values?.[originSlot] ?? srcNode.widgets_values?.[0];
|
||||
if (v && typeof v === "string") textMap.set(n.id, v);
|
||||
}
|
||||
}
|
||||
const text2 = resolveLinkedTextInNodeSet(nodeMap, linkOrigin, linkSlot, originId, originSlot);
|
||||
if (text2) textMap.set(n.id, text2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,15 +302,48 @@ function extractPromptsFromNodeSet(nodes, links) {
|
||||
for (const n of nodes) {
|
||||
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
|
||||
foundSampler = true;
|
||||
for (const inp of n.inputs) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role slots.
|
||||
let inputsToScan = n.inputs;
|
||||
const guiderInput = n.inputs.find(inp => inp.name === "guider");
|
||||
if (guiderInput?.link != null) {
|
||||
const guiderId = linkOrigin.get(guiderInput.link);
|
||||
const guiderNode = guiderId != null ? nodeMap.get(guiderId) : null;
|
||||
if (Array.isArray(guiderNode?.inputs)) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const inp of inputsToScan) {
|
||||
if (!inp || inp.link == null) continue;
|
||||
const originId = linkOrigin.get(inp.link);
|
||||
if (originId == null) continue;
|
||||
const text = textMap.get(originId);
|
||||
if (!text) continue;
|
||||
const name = inp.name ?? "";
|
||||
if (name === "positive" || name.startsWith("positive")) pos.add(text);
|
||||
else if (name === "negative" || name.startsWith("negative")) neg.add(text);
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = name === "positive" || name === "cond1" || name.startsWith("positive");
|
||||
const isNeg = name === "negative" || name.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
// negative_prompt directly, so textMap (single text per node) can't represent both
|
||||
// roles.
|
||||
const srcNode = nodeMap.get(originId);
|
||||
if (srcNode?.type === "TextEncodeMageFlowEdit" || srcNode?.type === "TextEncodeBooguEdit") {
|
||||
const widgetIdx = isPos ? 0 : 1; // widgets_values: [prompt, negative_prompt, ...]
|
||||
const text = srcNode.widgets_values?.[widgetIdx];
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
let resolvedOriginId = originId;
|
||||
if (srcNode?.type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs)) {
|
||||
const innerInput = srcNode.inputs.find(i => i.name === (isPos ? "positive" : "negative"));
|
||||
if (innerInput?.link != null) {
|
||||
const innerId = linkOrigin.get(innerInput.link);
|
||||
if (innerId != null) resolvedOriginId = innerId;
|
||||
}
|
||||
}
|
||||
const text = textMap.get(resolvedOriginId);
|
||||
if (!text) continue;
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler) return null;
|
||||
@@ -300,7 +395,8 @@ function extractMarkdownNoteModels(wf) {
|
||||
}
|
||||
|
||||
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
|
||||
function resolveLinkedText(wf, srcId, slot) {
|
||||
function resolveLinkedText(wf, srcId, slot, depth = 0) {
|
||||
if (depth > 6) return null;
|
||||
const src = wf[String(srcId)];
|
||||
if (!src || typeof src !== "object") return null;
|
||||
const ct = src.class_type ?? "";
|
||||
@@ -309,13 +405,34 @@ function resolveLinkedText(wf, srcId, slot) {
|
||||
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
|
||||
return (v && typeof v === "string") ? v : null;
|
||||
}
|
||||
// 汎用テキストキー
|
||||
// ComfySwitchNode ("If/Else Switch", on_false/on_true/switch) — Ernie Imageのプロンプト強化
|
||||
// トグルなどで使われる。片方(TextGenerateなどLLMノード)は静的解決不能なため両方試し、
|
||||
// リテラルへ解決できた方を採用する。
|
||||
if (ct === "ComfySwitchNode") {
|
||||
for (const key of ["on_false", "on_true"]) {
|
||||
const v = src.inputs?.[key];
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
} else if (typeof v === "string" && v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 汎用テキストキー("value" は PrimitiveString/PrimitiveStringMultiline、"source" は
|
||||
// PreviewAny の値タップ中継、"string_a" は StringConcatenate の主オペランド — いずれも
|
||||
// Krea-2のプロンプト強化配線で使われる中継ノード)
|
||||
const keys = slot === 0
|
||||
? ["text_positive", "text", "text_g", "prompt"]
|
||||
: ["text_negative", "text_l"];
|
||||
? ["text_positive", "text", "text_g", "prompt", "value", "source", "string_a"]
|
||||
: ["text_negative", "text_l", "source", "string_a"];
|
||||
for (const k of keys) {
|
||||
const v = src.inputs?.[k];
|
||||
if (v && typeof v === "string") return v;
|
||||
if (typeof v === "string" && v) return v;
|
||||
if (Array.isArray(v)) {
|
||||
const text = resolveLinkedText(wf, v[0], v[1] ?? 0, depth + 1);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -329,8 +446,23 @@ function extractPromptsAPI(wf) {
|
||||
}
|
||||
const textMap = new Map();
|
||||
for (const [id, n] of Object.entries(wf)) {
|
||||
if (!n || !isTextEncoderNode(n.class_type ?? "")) continue;
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? null;
|
||||
if (!n) continue;
|
||||
const ct = n.class_type ?? "";
|
||||
if (ct === "CLIPTextEncodeEditPlus") {
|
||||
const resolveField = (key) => {
|
||||
const v = n.inputs?.[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v)) return resolveLinkedText(wf, v[0], v[1] ?? 0);
|
||||
return null;
|
||||
};
|
||||
const combined = resolveEditPlusText(n.inputs?.mode, n.inputs?.text_edit, resolveField("text1"), resolveField("text2"));
|
||||
if (combined) textMap.set(id, combined);
|
||||
continue;
|
||||
}
|
||||
if (!isTextEncoderNode(ct)) continue;
|
||||
// "prompt" — TextEncodeQwenImageEdit(Plus)/TextEncodeBooguEdit and similar Image Edit
|
||||
// model text encoders use this key instead of "text"/"text_g".
|
||||
const raw = n.inputs?.text ?? n.inputs?.text_g ?? n.inputs?.prompt ?? null;
|
||||
if (raw && typeof raw === "string") {
|
||||
textMap.set(id, raw);
|
||||
} else if (Array.isArray(raw)) {
|
||||
@@ -344,12 +476,37 @@ function extractPromptsAPI(wf) {
|
||||
for (const n of Object.values(wf)) {
|
||||
if (!n || !isSamplerNode(n.class_type ?? "")) continue;
|
||||
foundSampler = true;
|
||||
for (const [key, val] of Object.entries(n.inputs ?? {})) {
|
||||
// SamplerCustomAdvanced doesn't hold positive/negative directly — it drives a separate
|
||||
// Guider node (CFGGuider/DualCFGGuider/BasicGuider) via its "guider" input. Scan that
|
||||
// node's inputs instead so the loop below can find the positive/negative-role keys.
|
||||
let inputsToScan = n.inputs ?? {};
|
||||
if (Array.isArray(n.inputs?.guider)) {
|
||||
const guiderNode = wf[String(n.inputs.guider[0])];
|
||||
if (guiderNode?.inputs) inputsToScan = guiderNode.inputs;
|
||||
}
|
||||
for (const [key, val] of Object.entries(inputsToScan)) {
|
||||
if (!Array.isArray(val)) continue;
|
||||
const text = textMap.get(String(val[0]));
|
||||
// DualCFGGuider (HiDream E1): cond1 carries the positive-derived conditioning.
|
||||
const isPos = key === "positive" || key === "cond1" || key.startsWith("positive");
|
||||
const isNeg = key === "negative" || key.startsWith("negative");
|
||||
if (!isPos && !isNeg) continue;
|
||||
// TextEncodeMageFlowEdit / TextEncodeBooguEdit — one node carries both prompt &
|
||||
// negative_prompt directly, so textMap (single text per node) can't represent both
|
||||
// roles.
|
||||
const srcNode = wf[String(val[0])];
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit") {
|
||||
const text = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
continue;
|
||||
}
|
||||
// InstructPixToPixConditioning (HiDream E1): forwards the actual text-encoder
|
||||
// conditioning through its own positive/negative inputs — follow one more hop.
|
||||
const resolvedId = srcNode?.class_type === "InstructPixToPixConditioning" && Array.isArray(srcNode.inputs?.[isPos ? "positive" : "negative"])
|
||||
? String(srcNode.inputs[isPos ? "positive" : "negative"][0])
|
||||
: String(val[0]);
|
||||
const text = textMap.get(resolvedId);
|
||||
if (!text) continue;
|
||||
if (key === "positive" || key.startsWith("positive")) pos.add(text);
|
||||
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
|
||||
if (isPos) pos.add(text); else neg.add(text);
|
||||
}
|
||||
}
|
||||
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
|
||||
@@ -446,15 +603,27 @@ function parseFooocusMetadata(raw) {
|
||||
}
|
||||
|
||||
// ── Master extractAllMetadata ─────────────────────────────────
|
||||
async function extractAllMetadata(file) {
|
||||
export async function extractAllMetadata(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
const isJSON = file.type === "application/json" || name.endsWith(".json");
|
||||
const isWebP = file.type === "image/webp" || name.endsWith(".webp");
|
||||
|
||||
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 +636,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);
|
||||
|
||||
+17
-1947
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Models Tab - Badge palette, badge rendering, badge edit modal, badge filter bar
|
||||
*/
|
||||
|
||||
import { openModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml, readJsonStorage } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
|
||||
// out yet); registered once from initModelsTab() to avoid a circular import.
|
||||
let _onGridChange = () => {};
|
||||
export function setGridChangeCallback(fn) { _onGridChange = fn; }
|
||||
|
||||
export function getBadgePalette() {
|
||||
return readJsonStorage("wfm_models_badge_palette");
|
||||
}
|
||||
|
||||
export function saveBadgePalette(palette) {
|
||||
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
|
||||
}
|
||||
|
||||
export function badgeHtml(label) {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
export function modelBadgesHtml(modelName, clickable = false) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const badges = meta.badges || [];
|
||||
if (badges.length === 0) return "";
|
||||
return badges.map((label) => {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
const dataAttr = clickable ? ` data-badge-filter="${escapeHtml(label)}"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model${clickable ? " wfm-badge-clickable" : ""}"${style}${dataAttr}>${escapeHtml(label)}</span>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function openBadgeEditModal(onPaletteChange = null) {
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
|
||||
const rowsHtml = labels.map((label) => {
|
||||
const color = palette[label] || "#6366f1";
|
||||
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
|
||||
${badgeHtml(label)}
|
||||
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">×</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<div style="min-width:320px;">
|
||||
<div id="wfm-badge-list">${rowsHtml || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`}</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);margin-top:12px;padding-top:12px;display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" id="wfm-badge-new-label" class="wfm-input" style="flex:1;" placeholder="${t("badgeNewLabel")}">
|
||||
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(t("badgeManage"), html);
|
||||
bindBadgeModalEvents(onPaletteChange);
|
||||
}
|
||||
|
||||
function bindBadgeModalEvents(onPaletteChange = null) {
|
||||
const afterChange = () => {
|
||||
renderBadgeFilter();
|
||||
_onGridChange();
|
||||
if (onPaletteChange) onPaletteChange();
|
||||
};
|
||||
|
||||
const refreshList = () => {
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
const listEl = document.getElementById("wfm-badge-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = labels.map((label) => {
|
||||
const color = palette[label] || "#6366f1";
|
||||
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
|
||||
${badgeHtml(label)}
|
||||
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">×</button>
|
||||
</div>`;
|
||||
}).join("") || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`;
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
afterChange();
|
||||
};
|
||||
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
|
||||
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
|
||||
const labelInput = document.getElementById("wfm-badge-new-label");
|
||||
const colorInput = document.getElementById("wfm-badge-new-color");
|
||||
const label = labelInput?.value.trim();
|
||||
if (!label) return;
|
||||
const palette = getBadgePalette();
|
||||
palette[label] = colorInput?.value || "#6366f1";
|
||||
saveBadgePalette(palette);
|
||||
if (labelInput) labelInput.value = "";
|
||||
refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
function bindBadgeRowEvents(refreshList, afterChange = null) {
|
||||
document.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
|
||||
input.addEventListener("input", (e) => {
|
||||
const label = e.target.dataset.badgeLabel;
|
||||
const palette = getBadgePalette();
|
||||
palette[label] = e.target.value;
|
||||
saveBadgePalette(palette);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-badge-delete-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const label = btn.dataset.badgeLabel;
|
||||
const palette = getBadgePalette();
|
||||
delete palette[label];
|
||||
saveBadgePalette(palette);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Badge Filter Bar ──────────────────────────────────────
|
||||
|
||||
export function renderBadgeFilter() {
|
||||
const container = document.getElementById("wfm-models-badge-filter-bar");
|
||||
if (!container) return;
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
|
||||
if (labels.length === 0) {
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = labels.map((label) => {
|
||||
const color = palette[label] || "";
|
||||
const isActive = state.badgeFilter === label;
|
||||
const style = color ? `background:${color};color:#fff;` : "";
|
||||
return `<button class="wfm-badge-filter-btn${isActive ? " active" : ""}" data-badge="${escapeHtml(label)}" style="${style}">${escapeHtml(label)}</button>`;
|
||||
}).join("");
|
||||
|
||||
container.querySelectorAll(".wfm-badge-filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const label = btn.dataset.badge;
|
||||
state.badgeFilter = state.badgeFilter === label ? "" : label;
|
||||
state.currentPage = 0;
|
||||
renderBadgeFilter();
|
||||
_onGridChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
/**
|
||||
* Models Tab - Detail modal (double-click) and the side panel's
|
||||
* Info / Group / CivitAI tabs.
|
||||
*
|
||||
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
|
||||
* import triangle — see the note at the top of grid-view.js.
|
||||
*/
|
||||
|
||||
import { showToast, openModal, closeModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
|
||||
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import {
|
||||
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
|
||||
fetchModelMetadata, saveModelGroups,
|
||||
} from "../models-tab.js";
|
||||
|
||||
// ── Detail Modal (double-click, with badges) ──────────────
|
||||
|
||||
export function openDetailModal(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const isFav = !!meta.favorite;
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const selectedBadges = meta.badges || [];
|
||||
const palette = getBadgePalette();
|
||||
const allBadgeLabels = Object.keys(palette).sort();
|
||||
|
||||
const badgeCheckboxes = allBadgeLabels.map((label) => {
|
||||
const checked = selectedBadges.includes(label) ? " checked" : "";
|
||||
const color = palette[label] || "";
|
||||
const style = color ? `background:${color};color:#fff;` : "";
|
||||
return `<label class="wfm-badge-check-label">
|
||||
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
|
||||
<span class="wfm-badge wfm-badge-model" style="${style}">${escapeHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<div class="wfm-modal-thumb-section">
|
||||
<img class="wfm-modal-thumb-img" style="display:none" />
|
||||
<div class="wfm-modal-thumb-placeholder">${t("modelsNoPreview")}</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:8px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-change-thumb">${t("changeThumbnail")}</button>
|
||||
<input type="file" id="wfm-modal-thumb-file" accept="image/*" style="display:none">
|
||||
</div>
|
||||
<div class="wfm-modal-two-col">
|
||||
<div class="wfm-modal-left">
|
||||
<section>
|
||||
<h4>${t("modelsInfo")}</h4>
|
||||
<div><span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>${dir ? ` <span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-modal-badge-manage" style="margin-left:6px;font-size:10px;">⚙ ${t("badgeManage")}</button></h4>
|
||||
<div id="wfm-modal-badge-checkboxes" class="wfm-badge-checkboxes">
|
||||
${allBadgeLabels.length === 0
|
||||
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
|
||||
: badgeCheckboxes}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsTags")} <span style="font-weight:normal;font-size:11px;">${t("modelsTagsHint")}</span></h4>
|
||||
<input type="text" class="wfm-input" id="wfm-modal-model-tags" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsMemo")}</h4>
|
||||
<textarea class="wfm-textarea" id="wfm-modal-model-memo" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-modal-model-save">${t("modelsSave")}</button>
|
||||
${state.activeModelType === "embedding"
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-pp" title="Positive Promptにエンベッディング追加">GenUI PP</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-np" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
|
||||
: (GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-model" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
|
||||
: "")}
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-modal-model-delete" style="margin-left:auto;">${t("modelsDelete")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-model-close">${t("close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(getStem(name), html);
|
||||
|
||||
// Load preview image
|
||||
const modalImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
const modalPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
|
||||
if (modalImg) loadPreviewImage(modalImg, modalPlaceholder, modelName);
|
||||
|
||||
// Add favorite button in header
|
||||
const titleEl = document.getElementById("wfm-modal-title");
|
||||
if (titleEl) {
|
||||
titleEl.parentNode.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
|
||||
const favBtn = document.createElement("button");
|
||||
favBtn.className = isFav ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
favBtn.style.cssText = "position:static;font-size:18px;margin-right:8px;";
|
||||
favBtn.textContent = isFav ? "★" : "☆";
|
||||
favBtn.addEventListener("click", async () => {
|
||||
const newVal = !meta.favorite;
|
||||
await saveModelMetadata(modelName, { favorite: newVal });
|
||||
meta.favorite = newVal;
|
||||
favBtn.textContent = newVal ? "★" : "☆";
|
||||
favBtn.classList.toggle("active", newVal);
|
||||
renderModelGrid();
|
||||
});
|
||||
titleEl.parentNode.insertBefore(favBtn, titleEl);
|
||||
}
|
||||
|
||||
// Badge manage button → open badge edit modal
|
||||
document.getElementById("wfm-modal-badge-manage")?.addEventListener("click", () => {
|
||||
openBadgeEditModal();
|
||||
});
|
||||
|
||||
// Save
|
||||
document.getElementById("wfm-modal-model-save")?.addEventListener("click", async () => {
|
||||
const tagsInput = document.getElementById("wfm-modal-model-tags");
|
||||
const memoInput = document.getElementById("wfm-modal-model-memo");
|
||||
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const memo = memoInput ? memoInput.value : "";
|
||||
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map((cb) => cb.value);
|
||||
await saveModelMetadata(modelName, { tags, memo, badges });
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons
|
||||
document.getElementById("wfm-modal-genui-model")?.addEventListener("click", () => {
|
||||
applyToGenUI(modelName, state.activeModelType);
|
||||
});
|
||||
document.getElementById("wfm-modal-genui-pp")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "positive");
|
||||
});
|
||||
document.getElementById("wfm-modal-genui-np")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
|
||||
// Close button (連続作業時にXボタンまでマウス移動しなくて済むように)
|
||||
document.getElementById("wfm-modal-model-close")?.addEventListener("click", () => {
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Delete model button
|
||||
document.getElementById("wfm-modal-model-delete")?.addEventListener("click", async () => {
|
||||
const confirmMsg = t("modelBulkDeleteConfirm").replace("{count}", "1");
|
||||
if (!confirm(confirmMsg)) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names: [modelName] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.errors?.length > 0) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${data.errors[0].error}`, "error");
|
||||
return;
|
||||
}
|
||||
showToast(`${getStem(name)} ${t("modelBulkDeleteDone")}`, "success");
|
||||
closeModal();
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(modelName);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
delete state.modelMetadata[modelName];
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds) ds.delete(modelName);
|
||||
state.selectedModels.delete(modelName);
|
||||
if (state.selectedModel === modelName) {
|
||||
state.selectedModel = null;
|
||||
const titleEl2 = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl2) titleEl2.textContent = "";
|
||||
}
|
||||
renderModelGrid();
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Change thumbnail
|
||||
const changeBtn = document.getElementById("wfm-modal-change-thumb");
|
||||
const thumbFile = document.getElementById("wfm-modal-thumb-file");
|
||||
if (changeBtn && thumbFile) {
|
||||
changeBtn.addEventListener("click", () => { thumbFile.value = ""; thumbFile.click(); });
|
||||
thumbFile.addEventListener("change", async () => {
|
||||
const file = thumbFile.files?.[0];
|
||||
if (!file) return;
|
||||
changeBtn.disabled = true;
|
||||
changeBtn.textContent = t("uploading");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("type", state.activeModelType);
|
||||
fd.append("name", modelName);
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
// Reload preview in modal
|
||||
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
|
||||
const mImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
const mPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
|
||||
if (mImg) { mImg.src = newUrl; mImg.style.display = ""; }
|
||||
if (mPlaceholder) mPlaceholder.style.display = "none";
|
||||
renderModelGrid();
|
||||
if (state.selectedModel === modelName) renderSideInfo(modelName);
|
||||
showToast(t("thumbnailChanged"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("thumbnailError") + ": " + err.message, "error");
|
||||
} finally {
|
||||
changeBtn.disabled = false;
|
||||
changeBtn.textContent = t("changeThumbnail");
|
||||
thumbFile.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side Panel ────────────────────────────────────────────
|
||||
|
||||
export function showSidePanel(modelName) {
|
||||
state.selectedModel = modelName;
|
||||
|
||||
// Highlight selected
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.toggle("wfm-card-selected", el.dataset.modelName === modelName);
|
||||
});
|
||||
|
||||
const { name } = parseModelPath(modelName);
|
||||
document.getElementById("wfm-models-panel-title").textContent = name;
|
||||
|
||||
// Reset to CivitAI tab
|
||||
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((b) => b.classList.remove("active"));
|
||||
document.querySelector('.wfm-models-side-tab-btn[data-side-tab="civitai"]')?.classList.add("active");
|
||||
document.querySelectorAll(".wfm-models-side-content").forEach((c) => (c.style.display = "none"));
|
||||
const civitaiEl = document.getElementById("wfm-models-side-civitai");
|
||||
if (civitaiEl) civitaiEl.style.display = "block";
|
||||
|
||||
const genuiNavBtn = document.getElementById("wfm-side-genui-nav-btn");
|
||||
const genuiNpBtn = document.getElementById("wfm-side-genui-np-btn");
|
||||
if (genuiNavBtn) {
|
||||
if (state.activeModelType === "embedding") {
|
||||
genuiNavBtn.textContent = "GenUI PP";
|
||||
genuiNavBtn.title = "Positive Promptにエンベッディング追加";
|
||||
genuiNavBtn.style.display = "";
|
||||
if (genuiNpBtn) {
|
||||
genuiNpBtn.textContent = "GenUI NP";
|
||||
genuiNpBtn.title = "Negative Promptにエンベッディング追加";
|
||||
genuiNpBtn.style.display = "";
|
||||
}
|
||||
} else if (GENUI_TYPE_MAP[state.activeModelType]) {
|
||||
genuiNavBtn.textContent = t("modelsGenUIBtn");
|
||||
genuiNavBtn.title = t("modelsGenUITitle");
|
||||
genuiNavBtn.style.display = "";
|
||||
if (genuiNpBtn) genuiNpBtn.style.display = "none";
|
||||
} else {
|
||||
genuiNavBtn.style.display = "none";
|
||||
if (genuiNpBtn) genuiNpBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
renderSideInfo(modelName);
|
||||
renderSideGroup(modelName);
|
||||
renderSideCivitai(modelName);
|
||||
}
|
||||
|
||||
export function closeSidePanel() {
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.remove("wfm-card-selected");
|
||||
});
|
||||
const npBtn = document.getElementById("wfm-side-genui-np-btn");
|
||||
if (npBtn) npBtn.style.display = "none";
|
||||
const ppBtn = document.getElementById("wfm-side-genui-nav-btn");
|
||||
if (ppBtn) ppBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Side Panel: Info Tab ──────────────────────────────────
|
||||
|
||||
export function renderSideInfo(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-info");
|
||||
if (!el) return;
|
||||
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const userBadgesHtml = modelBadgesHtml(modelName, false);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-side-thumb-container">
|
||||
<div class="wfm-side-thumb-img-wrap">
|
||||
<img style="display:none" />
|
||||
<span class="wfm-side-thumb-placeholder">${t("modelsNoPreview")}</span>
|
||||
</div>
|
||||
<div class="wfm-side-thumb-info">
|
||||
<div class="wfm-side-thumb-name wfm-model-name-copy" title="${t("modelsCopyName")}">${escapeHtml(name)}</div>
|
||||
<div class="wfm-side-thumb-meta">
|
||||
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
|
||||
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
|
||||
${userBadgesHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<input type="text" id="wfm-models-side-tags" class="wfm-search-input" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsMemo")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<textarea id="wfm-models-side-memo" class="wfm-textarea" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section" style="display:flex;gap:6px;">
|
||||
<button id="wfm-models-side-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsSave")}</button>
|
||||
${state.activeModelType === "embedding"
|
||||
? `<button id="wfm-models-side-genui-pp-btn" class="wfm-btn wfm-btn-sm" title="Positive Promptにエンベッディング追加">GenUI PP</button>
|
||||
<button id="wfm-models-side-genui-np-btn" class="wfm-btn wfm-btn-sm" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
|
||||
: (GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button id="wfm-models-side-genui-btn" class="wfm-btn wfm-btn-sm" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
|
||||
: "")}
|
||||
</div>`;
|
||||
|
||||
// Load preview image
|
||||
const sideImg = el.querySelector(".wfm-side-thumb-img-wrap img");
|
||||
const sidePlaceholder = el.querySelector(".wfm-side-thumb-placeholder");
|
||||
if (sideImg) loadPreviewImage(sideImg, sidePlaceholder, modelName);
|
||||
|
||||
// Copy model name on click
|
||||
el.querySelector(".wfm-model-name-copy")?.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(modelName).then(() => {
|
||||
showToast(t("modelsCopiedName"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and display file path
|
||||
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl && data.path) {
|
||||
fpEl.textContent = data.path;
|
||||
fpEl.title = t("modelsCopyPath");
|
||||
} else if (fpEl) {
|
||||
fpEl.textContent = modelName;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) fpEl.textContent = modelName;
|
||||
});
|
||||
|
||||
// Copy file path on click
|
||||
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) {
|
||||
navigator.clipboard.writeText(fpEl.textContent).then(() => {
|
||||
showToast(t("modelsCopiedPath"), "success");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save button
|
||||
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
|
||||
const tagsInput = document.getElementById("wfm-models-side-tags");
|
||||
const memoInput = document.getElementById("wfm-models-side-memo");
|
||||
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const memo = memoInput ? memoInput.value : "";
|
||||
saveModelMetadata(modelName, { tags, memo }).then(() => {
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons
|
||||
el.querySelector("#wfm-models-side-genui-btn")?.addEventListener("click", () => {
|
||||
applyToGenUI(modelName, state.activeModelType);
|
||||
});
|
||||
el.querySelector("#wfm-models-side-genui-pp-btn")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "positive");
|
||||
});
|
||||
el.querySelector("#wfm-models-side-genui-np-btn")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Side Panel: Group Tab ─────────────────────────────────
|
||||
|
||||
export function renderSideGroup(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-group");
|
||||
if (!el) return;
|
||||
|
||||
// Find groups this model belongs to
|
||||
const memberOf = [];
|
||||
for (const [gName, members] of Object.entries(state.modelGroups)) {
|
||||
if (members.includes(modelName)) memberOf.push(gName);
|
||||
}
|
||||
|
||||
// All group names for the assign dropdown
|
||||
const allGroups = Object.keys(state.modelGroups).sort();
|
||||
const availableGroups = allGroups.filter((g) => !memberOf.includes(g));
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map((g) => `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:13px;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-group-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-models-group-assign" class="wfm-select" style="flex:1;font-size:12px;">
|
||||
${availableGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: availableGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-assign-btn" ${availableGroups.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-models-group-new" class="wfm-search-input" style="flex:1;font-size:12px;" placeholder="${t("modelsGroupName")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-create-btn">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--wfm-border);padding-top:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<select id="wfm-models-group-manage-select" class="wfm-select" style="flex:1;font-size:12px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove from group
|
||||
el.querySelectorAll(".wfm-group-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const g = btn.dataset.group;
|
||||
const members = state.modelGroups[g] || [];
|
||||
state.modelGroups[g] = members.filter((m) => m !== modelName);
|
||||
if (state.modelGroups[g].length === 0) delete state.modelGroups[g];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to group
|
||||
document.getElementById("wfm-models-group-assign-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-assign");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
if (!state.modelGroups[g]) state.modelGroups[g] = [];
|
||||
if (!state.modelGroups[g].includes(modelName)) state.modelGroups[g].push(modelName);
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
// Create new group
|
||||
document.getElementById("wfm-models-group-create-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-models-group-new");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (state.modelGroups[name]) {
|
||||
showToast(t("modelsGroupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.modelGroups[name] = [modelName];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
// Rename group
|
||||
document.getElementById("wfm-models-group-rename-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
if (RESERVED_GROUPS.includes(oldName)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
const newName = prompt(t("modelsRenamePrompt"), oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.modelGroups[newName]) {
|
||||
showToast(t("modelsGroupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.modelGroups[newName] = state.modelGroups[oldName];
|
||||
delete state.modelGroups[oldName];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete group
|
||||
document.getElementById("wfm-models-group-delete-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
if (RESERVED_GROUPS.includes(g)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("modelsDeleteGroupConfirm").replace("{name}", g))) return;
|
||||
delete state.modelGroups[g];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Enable all models in selected group
|
||||
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
|
||||
});
|
||||
|
||||
// Disable all models in selected group
|
||||
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Side Panel: CivitAI Tab ───────────────────────────────
|
||||
|
||||
export function renderSideCivitai(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-civitai");
|
||||
if (!el) return;
|
||||
|
||||
// Check if we have cached civitai data via sha256
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const cached = sha256 && state.civitaiCache[sha256];
|
||||
|
||||
if (cached) {
|
||||
renderCivitaiInfo(el, cached, modelName);
|
||||
} else if (sha256) {
|
||||
// SHA256 is known but model was not found on CivitAI
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiNotFoundDesc")}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-fetch-btn">
|
||||
${t("civitaiRefetchBtn")}
|
||||
</button>
|
||||
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
|
||||
</div>`;
|
||||
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiFetchDesc")}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-civitai-fetch-btn">
|
||||
${t("civitaiFetch")}
|
||||
</button>
|
||||
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
|
||||
</div>`;
|
||||
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCivitaiForModel(modelName, el) {
|
||||
const statusEl = document.getElementById("wfm-civitai-status");
|
||||
const fetchBtn = document.getElementById("wfm-civitai-fetch-btn");
|
||||
if (fetchBtn) fetchBtn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = t("civitaiHashing");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: state.activeModelType, name: modelName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "ok" && data.civitai) {
|
||||
// Update caches
|
||||
if (data.sha256) {
|
||||
state.civitaiCache[data.sha256] = data.civitai;
|
||||
// Update metadata with sha256
|
||||
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
|
||||
state.modelMetadata[modelName].sha256 = data.sha256;
|
||||
}
|
||||
renderCivitaiInfo(el, data.civitai, modelName);
|
||||
showToast(t("civitaiFound"), "success");
|
||||
// Always refresh preview: local file if saved, otherwise civitai image as fallback
|
||||
const sidePanel = document.getElementById("wfm-models-side-panel");
|
||||
if (sidePanel) {
|
||||
const sideImg = sidePanel.querySelector(".wfm-side-thumb-img-wrap img");
|
||||
const sidePh = sidePanel.querySelector(".wfm-side-thumb-placeholder");
|
||||
if (sideImg) {
|
||||
if (data.preview_saved) {
|
||||
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
|
||||
} else {
|
||||
const civitaiImg = data.civitai.images && data.civitai.images[0];
|
||||
if (civitaiImg) sideImg.src = civitaiImg;
|
||||
}
|
||||
sideImg.style.display = "";
|
||||
if (sidePh) sidePh.style.display = "none";
|
||||
}
|
||||
}
|
||||
renderModelGrid();
|
||||
} else if (data.status === "not_found") {
|
||||
if (statusEl) statusEl.textContent = t("civitaiNotFound");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = data.error || t("civitaiError");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("CivitAI fetch error:", err);
|
||||
if (statusEl) statusEl.textContent = t("civitaiError");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCivitaiInfo(el, info, modelName) {
|
||||
// URL: ユーザー設定ホスト(localStorage)を使用、modelId なし時は /model-versions/ にフォールバック
|
||||
const civitaiHost = localStorage.getItem("wfm_civitai_host") || "civitai.com";
|
||||
const modelUrl = info.modelId && info.versionId
|
||||
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
|
||||
: info.versionId
|
||||
? `https://${civitaiHost}/model-versions/${info.versionId}`
|
||||
: (info.modelUrl || "#");
|
||||
|
||||
// Hash: BLAKE3 優先、なければ SHA256
|
||||
const fileHashes = info.fileHashes || {};
|
||||
const blake3 = fileHashes.BLAKE3 || fileHashes.Blake3 || "";
|
||||
const sha256 = fileHashes.SHA256 || (state.modelMetadata[modelName] || {}).sha256 || "";
|
||||
const hashType = blake3 ? "BLAKE3" : (sha256 ? "SHA256" : "");
|
||||
const hashFull = blake3 || sha256;
|
||||
|
||||
// Detail rows
|
||||
const ROW = "display:flex;align-items:center;font-size:12px;margin-bottom:5px;";
|
||||
const LABEL = "color:var(--wfm-text-secondary);min-width:80px;flex-shrink:0;";
|
||||
|
||||
const typeRow = info.type ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiType")}</span>
|
||||
<span style="font-size:10px;font-weight:700;background:var(--wfm-bg-tertiary,rgba(255,255,255,0.1));padding:2px 7px;border-radius:3px;letter-spacing:0.6px;">${escapeHtml(info.type.toUpperCase())}</span>
|
||||
</div>` : "";
|
||||
|
||||
const baseModelRow = info.baseModel ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiBaseModel")}</span>
|
||||
<span>${escapeHtml(info.baseModel)}</span>
|
||||
</div>` : "";
|
||||
|
||||
const hashRow = hashFull ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
|
||||
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
|
||||
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
|
||||
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
|
||||
</div>` : "";
|
||||
|
||||
const detailSection = (typeRow || baseModelRow || hashRow)
|
||||
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
|
||||
|
||||
const tagsHtml = (info.tags || []).map((tag) =>
|
||||
`<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`
|
||||
).join(" ");
|
||||
|
||||
const trainedWordsHtml = (info.trainedWords || []).map((w) =>
|
||||
`<code style="font-size:11px;background:var(--wfm-bg-secondary);padding:1px 4px;border-radius:3px;cursor:pointer;" class="wfm-trained-word" title="${t("civitaiCopyWord")}">${escapeHtml(w)}</code>`
|
||||
).join(" ");
|
||||
|
||||
// Sample images — all go to Sample pane
|
||||
const images = info.images || [];
|
||||
const sampleImagesHtml = images.map((url) =>
|
||||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
|
||||
).join("");
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="display:flex;border-bottom:1px solid var(--wfm-border);margin-bottom:10px;">
|
||||
<button class="wfm-civitai-subtab-btn" data-pane="info"
|
||||
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid var(--wfm-primary);color:var(--wfm-primary);font-weight:600;margin-bottom:-1px;">
|
||||
${t("civitaiTabInfo")}
|
||||
</button>
|
||||
<button class="wfm-civitai-subtab-btn" data-pane="sample"
|
||||
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;color:var(--wfm-text-secondary);margin-bottom:-1px;">
|
||||
${t("civitaiTabSample")}${images.length ? ` (${images.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="wfm-civitai-pane-info">
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="font-weight:700;font-size:14px;margin-bottom:2px;">
|
||||
<a href="${escapeHtml(modelUrl)}" target="_blank" style="color:var(--wfm-primary);text-decoration:none;">${escapeHtml(info.modelName)}</a>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--wfm-text-secondary);">
|
||||
${escapeHtml(info.versionName)}${info.creator ? ` · by ${escapeHtml(info.creator)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
${detailSection}
|
||||
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
|
||||
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
|
||||
<div style="margin-top:10px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wfm-civitai-pane-sample" style="display:none;">
|
||||
${sampleImagesHtml || `<p style="color:var(--wfm-text-secondary);font-size:13px;text-align:center;margin-top:20px;">${t("civitaiNoImages")}</p>`}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Sub-tab switching
|
||||
const subtabBtns = el.querySelectorAll(".wfm-civitai-subtab-btn");
|
||||
subtabBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const pane = btn.dataset.pane;
|
||||
subtabBtns.forEach((b) => {
|
||||
const isActive = b === btn;
|
||||
b.style.borderBottom = isActive ? "2px solid var(--wfm-primary)" : "2px solid transparent";
|
||||
b.style.color = isActive ? "var(--wfm-primary)" : "var(--wfm-text-secondary)";
|
||||
b.style.fontWeight = isActive ? "600" : "400";
|
||||
});
|
||||
el.querySelector("#wfm-civitai-pane-info").style.display = pane === "info" ? "" : "none";
|
||||
el.querySelector("#wfm-civitai-pane-sample").style.display = pane === "sample" ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// Copy trigger word on click
|
||||
el.querySelectorAll(".wfm-trained-word").forEach((wordEl) => {
|
||||
wordEl.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(wordEl.textContent).then(() => {
|
||||
showToast(t("civitaiWordCopied"), "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy hash on click
|
||||
el.querySelectorAll(".wfm-hash-value").forEach((hashEl) => {
|
||||
hashEl.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(hashEl.dataset.hash).then(() => {
|
||||
showToast(t("civitaiHashCopied"), "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
document.getElementById("wfm-civitai-refresh-btn")?.addEventListener("click", () => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
if (meta.sha256) delete state.civitaiCache[meta.sha256];
|
||||
delete meta.sha256;
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
}
|
||||
|
||||
// ── CivitAI cache / batch fetch ───────────────────────────
|
||||
|
||||
export async function fetchCivitaiCache() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/cache");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export async function batchFetchCivitai() {
|
||||
const models = getCurrentModels();
|
||||
if (models.length === 0) {
|
||||
showToast(t("modelsNoModels"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out models that already have CivitAI data
|
||||
const meta = state.modelMetadata;
|
||||
const uncached = models.filter((m) => {
|
||||
const sha = meta[m]?.sha256;
|
||||
return !sha || !state.civitaiCache[sha];
|
||||
});
|
||||
|
||||
if (uncached.length === 0) {
|
||||
showToast(t("civitaiBatchAllCached"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("wfm-models-civitai-batch-btn");
|
||||
const progressEl = document.getElementById("wfm-models-civitai-progress");
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: state.activeModelType, models: uncached }),
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let eventType = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
eventType = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === "progress") {
|
||||
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
|
||||
const statusText = data.status === "hashing" ? t("civitaiHashing2")
|
||||
: data.status === "fetching" ? t("civitaiFetching")
|
||||
: data.status === "cached" ? "✓"
|
||||
: data.status === "found" ? "✓"
|
||||
: data.status === "not_found" ? "—"
|
||||
: "";
|
||||
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
|
||||
} else if (eventType === "done") {
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
|
||||
// Reload caches
|
||||
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
|
||||
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
|
||||
// returns before the server has flushed updated metadata to disk
|
||||
if (data.hashes) {
|
||||
for (const [modelName, sha256] of Object.entries(data.hashes)) {
|
||||
if (!newMeta[modelName]) newMeta[modelName] = {};
|
||||
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
|
||||
}
|
||||
}
|
||||
state.modelMetadata = newMeta;
|
||||
state.civitaiCache = newCache;
|
||||
renderModelGrid();
|
||||
// Refresh side panel if open
|
||||
if (state.selectedModel) renderSideCivitai(state.selectedModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Batch CivitAI error:", err);
|
||||
showToast(t("civitaiError"), "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Models Tab - Filtering, sorting, and the tag/group/directory filter dropdowns
|
||||
*/
|
||||
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { parseModelPath, getExtension } from "./helpers.js";
|
||||
|
||||
export function getCurrentModels() {
|
||||
return state.modelsByType[state.activeModelType] || [];
|
||||
}
|
||||
|
||||
export function isModelDisabled(modelName) {
|
||||
const s = state.disabledModels[state.activeModelType];
|
||||
return s ? s.has(modelName) : false;
|
||||
}
|
||||
|
||||
export function filterModels() {
|
||||
let models = getCurrentModels();
|
||||
|
||||
if (state.statusFilter === "enabled") {
|
||||
models = models.filter((m) => !isModelDisabled(m));
|
||||
} else if (state.statusFilter === "disabled") {
|
||||
models = models.filter((m) => isModelDisabled(m));
|
||||
}
|
||||
|
||||
if (state.showFavoritesOnly) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.favorite;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.showBatchOnly) {
|
||||
const batchMembers = state.modelGroups["Batch"] || [];
|
||||
models = models.filter((m) => batchMembers.includes(m));
|
||||
}
|
||||
|
||||
if (state.tagFilter) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.tags && meta.tags.includes(state.tagFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.badgeFilter) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.badges && meta.badges.includes(state.badgeFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.dirFilter) {
|
||||
models = models.filter((m) => {
|
||||
const { dir } = parseModelPath(m);
|
||||
return dir === state.dirFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.groupFilter) {
|
||||
const members = state.modelGroups[state.groupFilter] || [];
|
||||
models = models.filter((m) => members.includes(m));
|
||||
}
|
||||
|
||||
if (state.searchText) {
|
||||
const q = state.searchText.toLowerCase();
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
const searchable = [m, ...(meta?.tags || []), meta?.memo || ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return sortModels(models);
|
||||
}
|
||||
|
||||
export function sortKeyOf(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
switch (state.sortColumn) {
|
||||
case "fav":
|
||||
return meta.favorite ? 1 : 0;
|
||||
case "filename":
|
||||
return parseModelPath(modelName).name.toLowerCase();
|
||||
case "subdir":
|
||||
return parseModelPath(modelName).dir.toLowerCase();
|
||||
case "civtype": {
|
||||
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
|
||||
return (civ?.type || "").toLowerCase();
|
||||
}
|
||||
case "basemodel": {
|
||||
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
|
||||
return (civ?.baseModel || "").toLowerCase();
|
||||
}
|
||||
case "ext":
|
||||
return getExtension(parseModelPath(modelName).name).toLowerCase();
|
||||
case "tags":
|
||||
return (meta.tags || []).join(", ").toLowerCase();
|
||||
case "memo":
|
||||
return (meta.memo || "").toLowerCase();
|
||||
case "enabled":
|
||||
return isModelDisabled(modelName) ? 1 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function sortModels(models) {
|
||||
if (!state.sortColumn) return models;
|
||||
// ソートキーを1モデル1回だけ計算(比較ごとのparseModelPath等の再計算を回避)
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
return models
|
||||
.map((m) => [sortKeyOf(m), m])
|
||||
.sort((a, b) => (a[0] < b[0] ? -dir : a[0] > b[0] ? dir : 0))
|
||||
.map((pair) => pair[1]);
|
||||
}
|
||||
|
||||
export function getAllTags() {
|
||||
const set = new Set();
|
||||
const models = getCurrentModels();
|
||||
models.forEach((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
if (meta?.tags) meta.tags.forEach((tag) => set.add(tag));
|
||||
});
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Render: Tag Filter ────────────────────────────────────
|
||||
|
||||
export function renderTagFilter() {
|
||||
const select = document.getElementById("wfm-models-tag-filter");
|
||||
if (!select) return;
|
||||
const tags = getAllTags();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllTags")}</option>` +
|
||||
tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
|
||||
select.value = state.tagFilter;
|
||||
}
|
||||
|
||||
export function renderGroupFilter() {
|
||||
const select = document.getElementById("wfm-models-group-filter");
|
||||
if (!select) return;
|
||||
|
||||
const groups = state.allModelGroups[state.activeModelType] || {};
|
||||
const names = Object.keys(groups).sort();
|
||||
|
||||
// 選択中のグループが削除された場合はフィルターをリセット
|
||||
if (state.groupFilter && !groups[state.groupFilter]) {
|
||||
state.groupFilter = "";
|
||||
}
|
||||
|
||||
const currentValue = state.groupFilter
|
||||
? `${state.activeModelType}::${state.groupFilter}`
|
||||
: "";
|
||||
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllGroups")}</option>` +
|
||||
names.map((name) => {
|
||||
const value = `${state.activeModelType}::${name}`;
|
||||
const isActive = value === currentValue;
|
||||
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>${escapeHtml(name)}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function renderDirFilter() {
|
||||
const select = document.getElementById("wfm-models-dir-filter");
|
||||
if (!select) return;
|
||||
const dirs = [...new Set(
|
||||
getCurrentModels()
|
||||
.map((m) => parseModelPath(m).dir)
|
||||
.filter((d) => d !== "")
|
||||
)].sort();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllDirs")}</option>` +
|
||||
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
|
||||
select.value = state.dirFilter;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Models Tab - Grid rendering (Thumbnail / Table views) and per-card
|
||||
* Batch/Stack/Favorite toggles.
|
||||
*
|
||||
* This module and selection-bulk.js / detail-panel.js form a circular
|
||||
* import triangle with each other and with models-tab.js: rendering a card
|
||||
* needs to wire up selection, side-panel, and detail-modal click handlers,
|
||||
* and those in turn re-render the grid after they change state. All the
|
||||
* cross-module calls happen inside event handlers (never at module
|
||||
* evaluation time), and every export here is a `function` declaration
|
||||
* (hoisted), so the cycle resolves safely under ES Modules.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { filterModels, getCurrentModels, isModelDisabled } from "./filters.js";
|
||||
import { modelBadgesHtml } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage } from "./helpers.js";
|
||||
import { toggleModelSelection } from "./selection-bulk.js";
|
||||
import { showSidePanel, openDetailModal, renderSideInfo } from "./detail-panel.js";
|
||||
import { saveModelGroups, saveModelMetadata, toggleModelEnable } from "../models-tab.js";
|
||||
|
||||
export function renderModelGrid() {
|
||||
const grid = document.getElementById("wfm-models-grid");
|
||||
if (!grid) return;
|
||||
|
||||
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
|
||||
const filtered = filterModels();
|
||||
|
||||
// Update count
|
||||
const countEl = document.getElementById("wfm-models-count");
|
||||
if (countEl) {
|
||||
const total = getCurrentModels().length;
|
||||
countEl.textContent = `${filtered.length} / ${total}`;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("modelsNoModels")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, filtered);
|
||||
} else {
|
||||
renderThumbView(grid, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thumbnail View (same as Workflow tab) ─────────────────
|
||||
|
||||
export function renderThumbView(grid, models) {
|
||||
grid.innerHTML = "";
|
||||
models.forEach((modelName) => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { name } = parseModelPath(modelName);
|
||||
const disabled = isModelDisabled(modelName);
|
||||
const userBadges = modelBadgesHtml(modelName);
|
||||
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "★" : "☆";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
|
||||
const showStackBtn = state.activeModelType === "lora";
|
||||
const inBatch = showBatchBtn && isInBatch(modelName);
|
||||
const inStack = showStackBtn && isInStack(modelName);
|
||||
const batchClass = inBatch ? "wfm-batch-btn active" : "wfm-batch-btn";
|
||||
const stackClass = inStack ? "wfm-stack-btn active" : "wfm-stack-btn";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
if (disabled) card.classList.add("wfm-model-disabled");
|
||||
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
|
||||
card.dataset.modelName = modelName;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-thumb">
|
||||
<img style="display:none" />
|
||||
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
|
||||
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
|
||||
</div>
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
|
||||
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
|
||||
</div>
|
||||
${showBatchBtn ? `<button class="${batchClass}" title="${t("modelsBatch")}">B</button>` : ""}
|
||||
${showStackBtn ? `<button class="${stackClass}" title="Stack (Lora multi-apply)">S</button>` : ""}
|
||||
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
|
||||
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
|
||||
|
||||
// Load preview without 404 console spam
|
||||
const img = card.querySelector(".wfm-card-thumb img");
|
||||
const placeholder = card.querySelector(".wfm-card-thumb-placeholder");
|
||||
loadPreviewImage(img, placeholder, modelName);
|
||||
|
||||
card.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleBatch(modelName);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
btn.classList.toggle("active", isInBatch(modelName));
|
||||
}
|
||||
});
|
||||
card.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleStack(modelName);
|
||||
btn.classList.toggle("active", isInStack(modelName));
|
||||
});
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(modelName);
|
||||
});
|
||||
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(modelName);
|
||||
});
|
||||
if (state.selectMode) {
|
||||
const isChecked = state.selectedModels.has(modelName);
|
||||
card.classList.toggle("wfm-card-checked", isChecked);
|
||||
const checkEl = document.createElement("div");
|
||||
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
|
||||
card.appendChild(checkEl);
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-batch-btn, .wfm-stack-btn, .wfm-fav-btn, .wfm-toggle-btn")) return;
|
||||
toggleModelSelection(modelName);
|
||||
});
|
||||
} else {
|
||||
card.addEventListener("click", () => showSidePanel(modelName));
|
||||
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Table View ────────────────────────────────────────────
|
||||
|
||||
export function thSortHtml(label, col, extraClass = "", extraStyle = "") {
|
||||
const isActive = state.sortColumn === col;
|
||||
const arrow = isActive ? (state.sortDir === "asc" ? " ▲" : " ▼") : "";
|
||||
const activeStyle = isActive ? "color:var(--wfm-accent,#6366f1);" : "";
|
||||
const cls = ["wfm-table-th-sortable", extraClass].filter(Boolean).join(" ");
|
||||
return `<th class="${cls}" data-sort-col="${col}" style="${activeStyle}${extraStyle}">${label}${arrow}</th>`;
|
||||
}
|
||||
|
||||
export function renderTableView(grid, models) {
|
||||
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
|
||||
const showStackBtn = state.activeModelType === "lora";
|
||||
const rows = models
|
||||
.map((modelName) => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiType = civitai ? (civitai.type || "") : "";
|
||||
const civitaiBaseModel = civitai ? (civitai.baseModel || "") : "";
|
||||
const disabled = isModelDisabled(modelName);
|
||||
const isChecked = state.selectMode && state.selectedModels.has(modelName);
|
||||
const favIcon = meta.favorite ? "★" : "☆";
|
||||
const batchIcon = !showBatchBtn ? "" : isInBatch(modelName) ? `<button class="wfm-batch-btn active" title="${t("modelsBatch")}">B</button>` : `<button class="wfm-batch-btn" title="${t("modelsBatch")}">B</button>`;
|
||||
const stackIcon = isInStack(modelName) ? `<button class="wfm-stack-btn active" title="Stack (Lora multi-apply)">S</button>` : `<button class="wfm-stack-btn" title="Stack (Lora multi-apply)">S</button>`;
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const memo = meta.memo || "";
|
||||
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
|
||||
const toggleIcon = disabled ? "▶" : "⏸";
|
||||
const checkCell = state.selectMode
|
||||
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
|
||||
: "";
|
||||
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
|
||||
${checkCell}
|
||||
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
|
||||
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
|
||||
<td class="wfm-table-td-filename" title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
|
||||
<td class="wfm-table-td-subdir">${escapeHtml(dir)}</td>
|
||||
<td class="wfm-table-td-civtype" title="${escapeHtml(civitaiType)}">${escapeHtml(civitaiType)}</td>
|
||||
<td class="wfm-table-td-basemodel" title="${escapeHtml(civitaiBaseModel)}">${escapeHtml(civitaiBaseModel)}</td>
|
||||
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
|
||||
<td>${escapeHtml(tagsStr)}</td>
|
||||
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
|
||||
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
|
||||
${showBatchBtn ? `<td class="wfm-table-td-batch">${batchIcon}</td>` : ""}
|
||||
${showStackBtn ? `<td class="wfm-table-td-stack">${stackIcon}</td>` : ""}
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
|
||||
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
|
||||
${checkTh}
|
||||
${thSortHtml("★", "fav", "", "width:30px;text-align:center;")}
|
||||
<th style="width:40px;"></th>
|
||||
${thSortHtml(t("modelsFileName"), "filename", "wfm-table-th-filename")}
|
||||
${thSortHtml(t("modelsSubdir"), "subdir", "wfm-table-th-subdir")}
|
||||
${thSortHtml(t("civitaiType"), "civtype", "wfm-table-th-civtype")}
|
||||
${thSortHtml(t("civitaiBaseModel"), "basemodel", "wfm-table-th-basemodel")}
|
||||
${thSortHtml(t("modelsExt"), "ext", "wfm-table-th-ext")}
|
||||
${thSortHtml(t("modelsTags"), "tags")}
|
||||
${thSortHtml(t("modelsMemo"), "memo")}
|
||||
${thSortHtml("E/D", "enabled", "", "width:50px;text-align:center;")}
|
||||
${showBatchBtn ? `<th style="width:30px;">B</th>` : ""}
|
||||
${showStackBtn ? `<th style="width:30px;">S</th>` : ""}
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
|
||||
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
|
||||
const mn = row.dataset.modelName;
|
||||
const img = row.querySelector(".wfm-table-thumb");
|
||||
if (img) loadPreviewImage(img, null, mn);
|
||||
|
||||
if (state.selectMode) {
|
||||
row.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn, .wfm-batch-btn, .wfm-stack-btn")) return;
|
||||
toggleModelSelection(mn);
|
||||
});
|
||||
} else {
|
||||
row.addEventListener("click", () => showSidePanel(mn));
|
||||
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
|
||||
}
|
||||
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(mn);
|
||||
});
|
||||
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(mn);
|
||||
});
|
||||
row.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await toggleBatch(mn);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
e.currentTarget.classList.toggle("active", isInBatch(mn));
|
||||
}
|
||||
});
|
||||
row.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await toggleStack(mn);
|
||||
e.currentTarget.classList.toggle("active", isInStack(mn));
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll(".wfm-table-th-sortable").forEach((th) => {
|
||||
th.addEventListener("click", () => {
|
||||
const col = th.dataset.sortCol;
|
||||
if (state.sortColumn === col) {
|
||||
if (state.sortDir === "asc") {
|
||||
state.sortDir = "desc";
|
||||
} else {
|
||||
state.sortColumn = null;
|
||||
state.sortDir = "asc";
|
||||
}
|
||||
} else {
|
||||
state.sortColumn = col;
|
||||
state.sortDir = "asc";
|
||||
}
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch group helpers ───────────────────────────────────
|
||||
|
||||
export function isInBatch(modelName) {
|
||||
return (state.modelGroups["Batch"] || []).includes(modelName);
|
||||
}
|
||||
|
||||
export async function toggleBatch(modelName) {
|
||||
const batch = state.modelGroups["Batch"] || [];
|
||||
const idx = batch.indexOf(modelName);
|
||||
if (idx >= 0) {
|
||||
batch.splice(idx, 1);
|
||||
} else {
|
||||
batch.push(modelName);
|
||||
}
|
||||
state.modelGroups["Batch"] = batch;
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
}
|
||||
|
||||
export async function clearBatchGroup() {
|
||||
state.modelGroups["Batch"] = [];
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
if (state.showBatchOnly) {
|
||||
state.showBatchOnly = false;
|
||||
document.getElementById("wfm-models-batch-filter-btn")?.classList.remove("active");
|
||||
}
|
||||
renderModelGrid();
|
||||
showToast(t("modelsBatchClear"), "success");
|
||||
}
|
||||
|
||||
export function isInStack(modelName) {
|
||||
return (state.modelGroups["Stack"] || []).includes(modelName);
|
||||
}
|
||||
|
||||
export async function toggleStack(modelName) {
|
||||
const stack = state.modelGroups["Stack"] || [];
|
||||
const idx = stack.indexOf(modelName);
|
||||
if (idx >= 0) {
|
||||
stack.splice(idx, 1);
|
||||
} else {
|
||||
stack.push(modelName);
|
||||
}
|
||||
state.modelGroups["Stack"] = stack;
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
}
|
||||
|
||||
export async function clearStackGroup() {
|
||||
state.modelGroups["Stack"] = [];
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
renderModelGrid();
|
||||
showToast(t("stackCleared"), "success");
|
||||
}
|
||||
|
||||
// ── Favorite toggle ───────────────────────────────────────
|
||||
|
||||
export async function toggleFavorite(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const newFav = !meta.favorite;
|
||||
await saveModelMetadata(modelName, { favorite: newFav });
|
||||
renderModelGrid();
|
||||
if (state.selectedModel === modelName) renderSideInfo(modelName);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Models Tab - Small path/preview helpers shared across the models/* modules
|
||||
*/
|
||||
|
||||
import { state } from "./state.js";
|
||||
|
||||
export function parseModelPath(fullName) {
|
||||
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
|
||||
if (lastSlash === -1) return { dir: "", name: fullName };
|
||||
return { dir: fullName.substring(0, lastSlash), name: fullName.substring(lastSlash + 1) };
|
||||
}
|
||||
|
||||
export function getExtension(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.substring(dot) : "";
|
||||
}
|
||||
|
||||
export function getStem(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
|
||||
export function previewUrl(modelName, modelType) {
|
||||
const type = modelType || state.activeModelType;
|
||||
return `/api/wfm/models/preview?type=${encodeURIComponent(type)}&name=${encodeURIComponent(modelName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preview image. Uses img onload/onerror instead of HEAD request
|
||||
* (aiohttp add_get does not auto-handle HEAD method).
|
||||
* Falls back to CivitAI cached image if no local preview exists.
|
||||
*/
|
||||
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const url = previewUrl(modelName, modelType);
|
||||
imgEl.onload = () => {
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
// Fallback: use CivitAI cached image if available
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiImg = civitai && civitai.images && civitai.images[0];
|
||||
if (civitaiImg) {
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
};
|
||||
imgEl.src = civitaiImg;
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
} else {
|
||||
imgEl.style.display = "none";
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
}
|
||||
};
|
||||
imgEl.src = url;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Models Tab - Multi-select mode and bulk group/badge/favorite/move/delete
|
||||
* operations on the current selection.
|
||||
*
|
||||
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
|
||||
* import triangle — see the note at the top of grid-view.js.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state, RESERVED_GROUPS } from "./state.js";
|
||||
import { filterModels, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette } from "./badges.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import { saveModelGroups, saveModelMetadata } from "../models-tab.js";
|
||||
|
||||
export function toggleSelectMode() {
|
||||
state.selectMode = !state.selectMode;
|
||||
if (!state.selectMode) state.selectedModels.clear();
|
||||
const btn = document.getElementById("wfm-models-select-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", state.selectMode);
|
||||
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
|
||||
}
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function toggleModelSelection(modelName) {
|
||||
if (state.selectedModels.has(modelName)) {
|
||||
state.selectedModels.delete(modelName);
|
||||
} else {
|
||||
state.selectedModels.add(modelName);
|
||||
}
|
||||
// Update DOM directly (avoid full re-render)
|
||||
document.querySelectorAll("[data-model-name]").forEach((el) => {
|
||||
if (el.dataset.modelName !== modelName) return;
|
||||
const checked = state.selectedModels.has(modelName);
|
||||
el.classList.toggle("wfm-card-checked", checked);
|
||||
const c = el.querySelector(".wfm-select-check");
|
||||
if (c) c.classList.toggle("checked", checked);
|
||||
});
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function clearSelection() {
|
||||
state.selectedModels.clear();
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function selectAll() {
|
||||
filterModels().forEach(m => state.selectedModels.add(m));
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function renderBulkActionBar() {
|
||||
const bar = document.getElementById("wfm-models-bulk-bar");
|
||||
if (!bar) return;
|
||||
if (!state.selectMode || state.selectedModels.size === 0) {
|
||||
bar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const count = state.selectedModels.size;
|
||||
const groupNames = Object.keys(state.modelGroups).sort();
|
||||
const noGroups = groupNames.length === 0;
|
||||
const groupOptions = noGroups
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
|
||||
|
||||
const palette = getBadgePalette();
|
||||
const badgeLabels = Object.keys(palette).sort();
|
||||
const noBadges = badgeLabels.length === 0;
|
||||
const badgeOptions = noBadges
|
||||
? `<option value="">${t("modelBulkNoBadge")}</option>`
|
||||
: badgeLabels.map((b) => `<option value="${escapeHtml(b)}">${escapeHtml(b)}</option>`).join("");
|
||||
|
||||
bar.style.display = "flex";
|
||||
bar.innerHTML = `
|
||||
<div class="wfm-bulk-header">
|
||||
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-deselect-all-btn">${t("modelBulkDeselectAll")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-select-all-btn">${t("modelBulkSelectAll")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-add-btn">${t("modelBulkFavAdd")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-remove-btn">${t("modelBulkFavRemove")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-rows">
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">Group:</span>
|
||||
<select id="wfm-bulk-group-select" class="wfm-select wfm-bulk-select">${groupOptions}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelsGroupName")}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">Badge:</span>
|
||||
<select id="wfm-bulk-badge-select" class="wfm-select wfm-bulk-select">${badgeOptions}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-badge-apply-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-badge-remove-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeRemove")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">File:</span>
|
||||
<select id="wfm-bulk-move-select" class="wfm-select wfm-bulk-select">
|
||||
<option value="">${t("modelBulkMoveRoot")}</option>
|
||||
${state.subdirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-move-btn">${t("modelBulkMoveBtn")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<input type="text" id="wfm-bulk-new-dir-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelBulkMoveNewFolder")}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-mkdir-move-btn">${t("modelBulkMoveCreateMove")}</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-bulk-deselect-all-btn")?.addEventListener("click", clearSelection);
|
||||
document.getElementById("wfm-bulk-select-all-btn")?.addEventListener("click", selectAll);
|
||||
document.getElementById("wfm-bulk-fav-add-btn")?.addEventListener("click", () => bulkSetFavorite(true));
|
||||
document.getElementById("wfm-bulk-fav-remove-btn")?.addEventListener("click", () => bulkSetFavorite(false));
|
||||
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
|
||||
const g = document.getElementById("wfm-bulk-group-select")?.value;
|
||||
if (g) bulkAddToGroup(g);
|
||||
});
|
||||
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
|
||||
const g = document.getElementById("wfm-bulk-group-select")?.value;
|
||||
if (g) bulkRemoveFromGroup(g);
|
||||
});
|
||||
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-bulk-new-group-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
|
||||
bulkAddToGroup(name);
|
||||
});
|
||||
document.getElementById("wfm-bulk-badge-apply-btn")?.addEventListener("click", () => {
|
||||
const label = document.getElementById("wfm-bulk-badge-select")?.value;
|
||||
if (label) bulkApplyBadge(label, true);
|
||||
});
|
||||
document.getElementById("wfm-bulk-badge-remove-btn")?.addEventListener("click", () => {
|
||||
const label = document.getElementById("wfm-bulk-badge-select")?.value;
|
||||
if (label) bulkApplyBadge(label, false);
|
||||
});
|
||||
document.getElementById("wfm-bulk-move-btn")?.addEventListener("click", () => {
|
||||
const dest = document.getElementById("wfm-bulk-move-select")?.value || "";
|
||||
bulkMoveModels(dest);
|
||||
});
|
||||
document.getElementById("wfm-bulk-mkdir-move-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-bulk-new-dir-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
bulkMoveModels(name);
|
||||
});
|
||||
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
|
||||
}
|
||||
|
||||
export async function bulkAddToGroup(groupName) {
|
||||
const groups = { ...state.modelGroups };
|
||||
if (!groups[groupName]) groups[groupName] = [];
|
||||
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
|
||||
groups[groupName] = [...groups[groupName], ...toAdd];
|
||||
await saveModelGroups(groups);
|
||||
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export async function bulkRemoveFromGroup(groupName) {
|
||||
const groups = { ...state.modelGroups };
|
||||
if (!groups[groupName]) return;
|
||||
const before = groups[groupName].length;
|
||||
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
|
||||
const removed = before - groups[groupName].length;
|
||||
// RESERVED_GROUPS (Batch / Stack) は空になってもキーを保持する
|
||||
if (groups[groupName].length === 0 && !RESERVED_GROUPS.includes(groupName)) delete groups[groupName];
|
||||
await saveModelGroups(groups);
|
||||
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export async function bulkSetFavorite(isFav) {
|
||||
const models = [...state.selectedModels];
|
||||
let count = 0;
|
||||
for (const mn of models) {
|
||||
const meta = state.modelMetadata[mn] || {};
|
||||
if (meta.favorite === isFav) continue;
|
||||
await saveModelMetadata(mn, { favorite: isFav });
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
showToast(`${count} ${isFav ? t("modelBulkFavDone") : t("modelBulkUnfavDone")}`, "success");
|
||||
renderModelGrid();
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkApplyBadge(badgeLabel, add) {
|
||||
const models = [...state.selectedModels];
|
||||
let count = 0;
|
||||
for (const mn of models) {
|
||||
const meta = state.modelMetadata[mn] || {};
|
||||
const badges = [...(meta.badges || [])];
|
||||
if (add) {
|
||||
if (badges.includes(badgeLabel)) continue;
|
||||
badges.push(badgeLabel);
|
||||
} else {
|
||||
const idx = badges.indexOf(badgeLabel);
|
||||
if (idx === -1) continue;
|
||||
badges.splice(idx, 1);
|
||||
}
|
||||
await saveModelMetadata(mn, { badges });
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
showToast(`${count} ${add ? t("modelBulkBadgeApplyDone") : t("modelBulkBadgeRemoveDone")}`, "success");
|
||||
renderModelGrid();
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteModels() {
|
||||
const count = state.selectedModels.size;
|
||||
if (count === 0) return;
|
||||
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
const model_names = [...state.selectedModels];
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const okCount = (data.ok || []).length;
|
||||
const errCount = (data.errors || []).length;
|
||||
if (errCount > 0) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
|
||||
}
|
||||
if (okCount > 0) {
|
||||
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
|
||||
// Remove deleted models from local state
|
||||
model_names.forEach((mn) => {
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(mn);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
delete state.modelMetadata[mn];
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds) ds.delete(mn);
|
||||
state.selectedModels.delete(mn);
|
||||
});
|
||||
state.loaded[state.activeModelType] = false;
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSubdirs() {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/subdirs?type=${encodeURIComponent(state.activeModelType)}`);
|
||||
state.subdirs = res.ok ? await res.json() : [];
|
||||
} catch { state.subdirs = []; }
|
||||
}
|
||||
|
||||
export async function bulkMoveModels(destSubdir) {
|
||||
const model_names = [...state.selectedModels];
|
||||
if (model_names.length === 0) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names, dest: destSubdir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const okCount = data.moved?.length || 0;
|
||||
const errCount = data.errors?.length || 0;
|
||||
|
||||
if (okCount > 0) {
|
||||
data.moved.forEach(({ from, to }) => {
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(from);
|
||||
if (idx !== -1) list[idx] = to;
|
||||
if (state.modelMetadata[from]) {
|
||||
state.modelMetadata[to] = state.modelMetadata[from];
|
||||
delete state.modelMetadata[from];
|
||||
}
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds && ds.has(from)) { ds.delete(from); ds.add(to); }
|
||||
state.selectedModels.delete(from);
|
||||
for (const members of Object.values(state.modelGroups)) {
|
||||
const gi = members.indexOf(from);
|
||||
if (gi !== -1) members[gi] = to;
|
||||
}
|
||||
});
|
||||
if (state.selectedModel && data.moved.some((m) => m.from === state.selectedModel)) {
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
}
|
||||
showToast(`${okCount} ${t("modelBulkMoveDone")}`, "success");
|
||||
await fetchSubdirs();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
if (errCount > 0) {
|
||||
showToast(`${t("modelBulkMoveError")}: ${data.errors[0].error}`, "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkMoveError")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Models Tab - Shared state and static lookup tables
|
||||
* `state` is a `const` object never reassigned — modules read/write its
|
||||
* properties directly, which stays within ESM's read-only binding rule
|
||||
* (only the binding itself, not the object it points to, is immutable).
|
||||
*/
|
||||
|
||||
import { comfyUI } from "../comfyui-client.js";
|
||||
|
||||
export const RESERVED_GROUPS = ["Batch", "Stack"];
|
||||
export const BATCH_MODEL_TYPES = ["checkpoint", "lora"];
|
||||
export const STACK_MODEL_TYPES = ["lora"];
|
||||
|
||||
export const state = {
|
||||
modelsByType: {
|
||||
checkpoint: [],
|
||||
lora: [],
|
||||
vae: [],
|
||||
controlnet: [],
|
||||
unet: [],
|
||||
textencoder: [],
|
||||
hypernetwork: [],
|
||||
embedding: [],
|
||||
},
|
||||
modelMetadata: {},
|
||||
modelGroups: {},
|
||||
allModelGroups: {}, // { type: { groupName: [models] } } — all types
|
||||
civitaiCache: {},
|
||||
disabledModels: {}, // { type: Set<modelName> }
|
||||
subdirs: [],
|
||||
selectMode: false,
|
||||
selectedModels: new Set(),
|
||||
searchText: "",
|
||||
tagFilter: "",
|
||||
badgeFilter: "",
|
||||
dirFilter: "",
|
||||
groupFilter: "",
|
||||
statusFilter: "all", // "all" | "enabled" | "disabled"
|
||||
showFavoritesOnly: false,
|
||||
showBatchOnly: false,
|
||||
viewMode: localStorage.getItem("wfm_models_view") === "table" ? "table" : "thumb",
|
||||
activeModelType: "checkpoint",
|
||||
selectedModel: null,
|
||||
loaded: {},
|
||||
currentPage: 0,
|
||||
sortColumn: null, // "fav" | "filename" | "subdir" | "civtype" | "basemodel" | "ext" | "tags" | "memo" | "enabled"
|
||||
sortDir: "asc", // "asc" | "desc"
|
||||
};
|
||||
|
||||
|
||||
export const FETCH_MAP = {
|
||||
checkpoint: () => comfyUI.fetchCheckpoints(),
|
||||
lora: () => comfyUI.fetchLoras(),
|
||||
vae: () => comfyUI.fetchVaes(),
|
||||
controlnet: () => comfyUI.fetchControlNets(),
|
||||
unet: () => comfyUI.fetchDiffusionModels(),
|
||||
textencoder: () => comfyUI.fetchTextEncoders(),
|
||||
hypernetwork: () => comfyUI.fetchHypernetworks(),
|
||||
embedding: () => comfyUI.fetchEmbeddings(),
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
checkpoint: "Checkpoint",
|
||||
lora: "LoRA",
|
||||
vae: "VAE",
|
||||
controlnet: "ControlNet",
|
||||
unet: "UNET",
|
||||
textencoder: "TextEncoder",
|
||||
hypernetwork: "Hypernetwork",
|
||||
embedding: "Embedding",
|
||||
};
|
||||
|
||||
// Mapping from models-tab type → comfyui-editor key + inputKey
|
||||
export const GENUI_TYPE_MAP = {
|
||||
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
|
||||
lora: { key: "loras", inputKey: "lora_name" },
|
||||
vae: { key: "vaes", inputKey: "vae_name" },
|
||||
controlnet: { key: "controlNets", inputKey: "control_net_name" },
|
||||
unet: { key: "diffusionModels",inputKey: "unet_name" },
|
||||
textencoder: { key: "textEncoders", inputKey: "clip_name1" },
|
||||
hypernetwork: { key: "hypernetworks", inputKey: "hypernetwork_name" },
|
||||
};
|
||||
+226
-3
@@ -4,7 +4,8 @@
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { readJsonStorage } from "./util.js";
|
||||
import { readJsonStorage, getAiBackendDefaultUrl, setupSearchClearBtn } from "./util.js";
|
||||
import { refreshStylesList } from "./generate-tab.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -390,7 +391,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 };
|
||||
}
|
||||
|
||||
@@ -418,7 +419,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 {
|
||||
@@ -903,6 +904,144 @@ function wcUpdateFilePicker() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Style manager (Col3 "Style" タブ)
|
||||
// ============================================
|
||||
// Wildcardファイルマネージャーと同じCRUDパターン(一覧→エディタ→保存/削除/キャンセル)。
|
||||
// 保存・削除後は generate-tab.js の refreshStylesList() を呼び、GenerateUIのStyleドロップダウンや
|
||||
// Batchタブのチェックリストを再読み込み無しで最新状態へ同期する。
|
||||
|
||||
let styleListData = [];
|
||||
let styleSearchText = "";
|
||||
let styleEditingOriginalName = null; // null = 新規作成
|
||||
let styleEditingFile = null; // 編集中/追加先スタイルの定義元ファイル名(style/配下、null = デフォルトのcustom.json)
|
||||
|
||||
async function styleFetchList() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/styles");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function styleApiCreate(name, prompt, negativePrompt, targetFile) {
|
||||
try {
|
||||
const body = { name, prompt, negative_prompt: negativePrompt };
|
||||
if (targetFile) body.file = targetFile;
|
||||
const res = await fetch("/api/wfm/styles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function styleApiUpdate(originalName, name, prompt, negativePrompt) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(originalName)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, prompt, negative_prompt: negativePrompt }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function styleApiDelete(name) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/styles/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: data.error || `HTTP ${res.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function styleRenderList() {
|
||||
const list = document.getElementById("wfm-style-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
const q = styleSearchText.trim().toLowerCase();
|
||||
const filtered = q ? styleListData.filter(s => s.name.toLowerCase().includes(q)) : styleListData;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = styleListData.length === 0
|
||||
? `<div class="wfm-pm-empty" style="font-size:12px;">No styles yet.<br><small>Click "+ New" to create one.</small></div>`
|
||||
: `<div class="wfm-pm-empty" style="font-size:12px;">No matching styles.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of filtered) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.name)}</span>
|
||||
${s.file ? `<span class="wfm-wc-file-ext" title="${esc(s.file)}">${esc(s.file)}</span>` : ""}
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-style-edit-btn" title="Edit style">✎</button>
|
||||
</div>
|
||||
`;
|
||||
item.querySelector(".wfm-style-edit-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
styleOpenEditor(s);
|
||||
});
|
||||
item.addEventListener("click", () => styleOpenEditor(s));
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// forcedFile: 「+ Add to this file」から呼ばれた場合に、既存スタイルではなく
|
||||
// 特定ファイルを追加先として指定するために使う(styleは渡さずnullにする)
|
||||
function styleOpenEditor(style, forcedFile) {
|
||||
styleEditingOriginalName = style ? style.name : null;
|
||||
styleEditingFile = style ? (style.file || null) : (forcedFile || null);
|
||||
|
||||
const editor = document.getElementById("wfm-style-editor");
|
||||
const nameInput = document.getElementById("wfm-style-editor-name");
|
||||
const promptTA = document.getElementById("wfm-style-editor-prompt");
|
||||
const negativeTA = document.getElementById("wfm-style-editor-negative");
|
||||
const deleteBtn = document.getElementById("wfm-style-editor-delete-btn");
|
||||
const addFileBtn = document.getElementById("wfm-style-editor-addfile-btn");
|
||||
const fileLabel = document.getElementById("wfm-style-editor-file-label");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = style?.name || "";
|
||||
promptTA.value = style?.prompt || "";
|
||||
negativeTA.value = style?.negative_prompt || "";
|
||||
deleteBtn.style.display = style ? "" : "none";
|
||||
// 「このファイルへ追加」は既存スタイルを編集中(=対象ファイルが分かっている)時だけ表示
|
||||
addFileBtn.style.display = style ? "" : "none";
|
||||
if (styleEditingFile) {
|
||||
fileLabel.textContent = `File: ${styleEditingFile}`;
|
||||
fileLabel.style.display = "";
|
||||
} else {
|
||||
fileLabel.style.display = "none";
|
||||
}
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function styleCloseEditor() {
|
||||
styleEditingOriginalName = null;
|
||||
styleEditingFile = null;
|
||||
const editor = document.getElementById("wfm-style-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
async function styleRefreshList() {
|
||||
styleListData = await styleFetchList();
|
||||
styleRenderList();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialize
|
||||
// ============================================
|
||||
@@ -1319,4 +1458,88 @@ export function initPromptTab() {
|
||||
|
||||
// Initial wildcard file load
|
||||
wcRefreshFiles();
|
||||
|
||||
// ── Col3タブ切り替え (Wildcard / Style) ────────────────────
|
||||
document.querySelectorAll(".wfm-prompt-col3-tab[data-col3tab]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".wfm-prompt-col3-tab[data-col3tab]").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const tabId = btn.dataset.col3tab;
|
||||
document.querySelectorAll(".wfm-prompt-col3-tab-content").forEach(c => { c.style.display = "none"; });
|
||||
const content = document.getElementById(`wfm-col3-tab-${tabId}`);
|
||||
if (content) content.style.display = "";
|
||||
});
|
||||
});
|
||||
|
||||
// ── Style manager ────────────────────────────────
|
||||
document.getElementById("wfm-style-new-btn")?.addEventListener("click", () => {
|
||||
styleOpenEditor(null);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-style-refresh-btn")?.addEventListener("click", () => {
|
||||
styleRefreshList();
|
||||
});
|
||||
|
||||
// 編集中の既存スタイルと同じファイルへ、新しい空のスタイルを追加する
|
||||
document.getElementById("wfm-style-editor-addfile-btn")?.addEventListener("click", () => {
|
||||
if (!styleEditingFile) return;
|
||||
styleOpenEditor(null, styleEditingFile);
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-style-search", "wfm-style-search-clear-btn", () => {
|
||||
styleSearchText = "";
|
||||
styleRenderList();
|
||||
});
|
||||
document.getElementById("wfm-style-search")?.addEventListener("input", (e) => {
|
||||
styleSearchText = e.target.value;
|
||||
styleRenderList();
|
||||
});
|
||||
|
||||
document.getElementById("wfm-style-editor-save-btn")?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-style-editor-name");
|
||||
const promptTA = document.getElementById("wfm-style-editor-prompt");
|
||||
const negativeTA = document.getElementById("wfm-style-editor-negative");
|
||||
|
||||
const name = (nameInput?.value || "").trim();
|
||||
const prompt = promptTA?.value || "";
|
||||
const negativePrompt = negativeTA?.value || "";
|
||||
|
||||
if (!name) { showToast(t("pleaseEnterStyleName"), "error"); return; }
|
||||
|
||||
const result = styleEditingOriginalName
|
||||
? await styleApiUpdate(styleEditingOriginalName, name, prompt, negativePrompt)
|
||||
: await styleApiCreate(name, prompt, negativePrompt, styleEditingFile);
|
||||
|
||||
if (result.ok) {
|
||||
showToast(t("savedAs", name), "success");
|
||||
styleCloseEditor();
|
||||
await styleRefreshList();
|
||||
await refreshStylesList();
|
||||
} else {
|
||||
showToast(t("errorWithMsg", result.error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: delete
|
||||
document.getElementById("wfm-style-editor-delete-btn")?.addEventListener("click", async () => {
|
||||
if (!styleEditingOriginalName) return;
|
||||
if (!confirm(`Delete "${styleEditingOriginalName}"?`)) return;
|
||||
const result = await styleApiDelete(styleEditingOriginalName);
|
||||
if (result.ok) {
|
||||
showToast(t("deletedName", styleEditingOriginalName), "success");
|
||||
styleCloseEditor();
|
||||
await styleRefreshList();
|
||||
await refreshStylesList();
|
||||
} else {
|
||||
showToast(t("errorWithMsg", result.error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: cancel
|
||||
document.getElementById("wfm-style-editor-cancel-btn")?.addEventListener("click", () => {
|
||||
styleCloseEditor();
|
||||
});
|
||||
|
||||
// Initial style list load
|
||||
styleRefreshList();
|
||||
}
|
||||
|
||||
+108
-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";
|
||||
|
||||
@@ -366,6 +366,21 @@ export function applyJsonColors(colors) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// GenerateUI Model tab: highlight color for section labels that match the loaded workflow
|
||||
const MODEL_ACTIVE_COLOR_STYLE_ID = "wfm-model-active-color-style";
|
||||
const MODEL_ACTIVE_COLOR_DEFAULT = "#4ade80";
|
||||
|
||||
export function applyModelTabActiveColor(color) {
|
||||
const val = color || MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
let el = document.getElementById(MODEL_ACTIVE_COLOR_STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
@@ -577,6 +592,21 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- GenerateUI Model Tab Highlight Color -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("modelTabColorSection") || "GenerateUI Modelタブ ハイライト色"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("modelTabColorLabel") || "ワークフローに含まれる項目の色"}</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="color" id="wfm-model-active-color" value="${settings.modelTabActiveColor || MODEL_ACTIVE_COLOR_DEFAULT}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-model-active-color-reset">${t("jsonColorReset") || "デフォルトに戻す"}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("modelTabColorHint") || "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
@@ -637,6 +667,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 +703,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">
|
||||
@@ -1101,14 +1158,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1117,7 +1174,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") {
|
||||
@@ -1142,7 +1199,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;
|
||||
@@ -1210,6 +1267,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");
|
||||
@@ -1277,6 +1363,22 @@ export async function initSettingsTab() {
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// --- GenerateUI Model tab highlight color ---
|
||||
document.getElementById("wfm-model-active-color")?.addEventListener("input", (e) => {
|
||||
saveLocalSettings({ modelTabActiveColor: e.target.value });
|
||||
applyModelTabActiveColor(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-model-active-color-reset")?.addEventListener("click", () => {
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.modelTabActiveColor;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyModelTabActiveColor(undefined);
|
||||
const inp = document.getElementById("wfm-model-active-color");
|
||||
if (inp) inp.value = MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Save (local settings)
|
||||
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
|
||||
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Style Catalog Gallery — Gallery タブのサブタブ。
|
||||
* ws_style_catalog フォルダ配下の画像(GenerateUIの「カタログ作成」で生成した、
|
||||
* ファイル名=スタイル名のプレビュー画像)を閲覧し、埋め込みPositive/Negative
|
||||
* プロンプトを表示・コピーする。「Select as Style」でGenerateUIの登録済み
|
||||
* Styleドロップダウンをファイル名と同名のStyleに切り替える。
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
import { extractAllMetadata, loadFileIntoMetadataTab } from "./metadata-tab.js";
|
||||
import { selectStyleByName, loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
|
||||
const API = {
|
||||
root: () => `/wfm/gallery/style-catalog/root`,
|
||||
folders: (root) => `/wfm/gallery/folders?root=${encodeURIComponent(root)}`,
|
||||
images: (params) => `/wfm/gallery/images?${new URLSearchParams(params)}`,
|
||||
thumb: (path, w = 200) => `/wfm/gallery/image/thumb?path=${encodeURIComponent(path)}&w=${w}`,
|
||||
serveImage: (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`,
|
||||
workflow: (path) => `/wfm/gallery/image/workflow?path=${encodeURIComponent(path)}`,
|
||||
};
|
||||
|
||||
const state = {
|
||||
root: null,
|
||||
currentFolder: null,
|
||||
search: "",
|
||||
images: [],
|
||||
selectedImage: null,
|
||||
};
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function debounce(fn, wait) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 初期化 ────────────────────────────────────────────────────
|
||||
|
||||
export function initStyleCatalogTab() {
|
||||
document.getElementById("wfm-stylecatalog-refresh-btn")?.addEventListener("click", () => {
|
||||
loadFolderTree();
|
||||
loadImages();
|
||||
});
|
||||
|
||||
setupSearchClearBtn("wfm-stylecatalog-search", "wfm-stylecatalog-search-clear-btn", () => {
|
||||
state.search = "";
|
||||
loadImages();
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById("wfm-stylecatalog-search");
|
||||
searchInput?.addEventListener("input", debounce(() => {
|
||||
state.search = searchInput.value.trim();
|
||||
loadImages();
|
||||
}, 300));
|
||||
|
||||
document.getElementById("wfm-stylecatalog-copy-positive-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-positive-text"));
|
||||
document.getElementById("wfm-stylecatalog-copy-negative-btn")?.addEventListener("click", () => copyText("wfm-stylecatalog-negative-text"));
|
||||
document.getElementById("wfm-stylecatalog-select-style-btn")?.addEventListener("click", onSelectStyleClick);
|
||||
document.getElementById("wfm-stylecatalog-load-genui-btn")?.addEventListener("click", onLoadGenUIClick);
|
||||
document.getElementById("wfm-stylecatalog-open-metadata-btn")?.addEventListener("click", onOpenMetadataClick);
|
||||
}
|
||||
|
||||
export async function activateStyleCatalogTab() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
try {
|
||||
const data = await apiFetch(API.root());
|
||||
state.root = data.root;
|
||||
await loadFolderTree();
|
||||
} catch (e) {
|
||||
const tree = document.getElementById("wfm-stylecatalog-tree");
|
||||
if (tree) tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── フォルダツリー ────────────────────────────────────────────
|
||||
|
||||
async function loadFolderTree() {
|
||||
if (!state.root) return;
|
||||
const tree = document.getElementById("wfm-stylecatalog-tree");
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
try {
|
||||
const data = await apiFetch(API.folders(state.root));
|
||||
if (data.error) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">${escapeHtml(data.error)}</p>`;
|
||||
return;
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTreeNode(node, container, depth, isRoot) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-gallery-tree-item";
|
||||
item.style.paddingLeft = `${depth * 12}px`;
|
||||
item.dataset.path = node.abs_path;
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const absPath = node.abs_path;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "wfm-gallery-tree-arrow";
|
||||
if (hasChildren) {
|
||||
arrow.textContent = "▶";
|
||||
arrow.dataset.expanded = "false";
|
||||
} else {
|
||||
arrow.textContent = " ";
|
||||
arrow.style.visibility = "hidden";
|
||||
}
|
||||
item.appendChild(arrow);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "wfm-gallery-tree-label";
|
||||
label.textContent = isRoot ? "[root]" : node.name;
|
||||
const totalCount = node.image_count_total ?? node.image_count;
|
||||
if (totalCount > 0) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "wfm-gallery-tree-count";
|
||||
badge.textContent = totalCount;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
item.appendChild(label);
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
document.querySelectorAll("#wfm-stylecatalog-tree .wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
|
||||
item.classList.add("selected");
|
||||
state.currentFolder = absPath;
|
||||
loadImages();
|
||||
});
|
||||
|
||||
if (hasChildren) {
|
||||
const childContainer = document.createElement("div");
|
||||
childContainer.className = "wfm-gallery-tree-children";
|
||||
childContainer.style.display = "none";
|
||||
|
||||
arrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const expanded = arrow.dataset.expanded === "true";
|
||||
if (!expanded) {
|
||||
arrow.dataset.expanded = "true";
|
||||
arrow.textContent = "▼";
|
||||
childContainer.style.display = "";
|
||||
if (childContainer.children.length === 0) {
|
||||
node.children.forEach(child => renderTreeNode(child, childContainer, depth + 1, false));
|
||||
}
|
||||
} else {
|
||||
arrow.dataset.expanded = "false";
|
||||
arrow.textContent = "▶";
|
||||
childContainer.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(item);
|
||||
container.appendChild(childContainer);
|
||||
} else {
|
||||
container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画像一覧 ─────────────────────────────────────────────────
|
||||
|
||||
async function loadImages() {
|
||||
if (!state.currentFolder) return;
|
||||
const grid = document.getElementById("wfm-stylecatalog-grid");
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
|
||||
|
||||
// 上位フォルダを選択した場合もサブフォルダの画像をまとめて表示する
|
||||
const params = { folder: state.currentFolder, sort: "name_asc", recursive: "true" };
|
||||
if (state.search) params.search = state.search;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(API.images(params));
|
||||
state.images = data.images || [];
|
||||
document.getElementById("wfm-stylecatalog-count").textContent = `${state.images.length} images`;
|
||||
renderImages();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">Error: ${escapeHtml(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const grid = document.getElementById("wfm-stylecatalog-grid");
|
||||
if (state.images.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("galleryNoImages")}</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.images.forEach(img => fragment.appendChild(createThumbCard(img)));
|
||||
grid.appendChild(fragment);
|
||||
}
|
||||
|
||||
function createThumbCard(img) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-gallery-thumb-card";
|
||||
card.title = img.filename;
|
||||
card.dataset.path = img.path;
|
||||
if (state.selectedImage && state.selectedImage.path === img.path) {
|
||||
card.classList.add("selected");
|
||||
}
|
||||
|
||||
const imgEl = document.createElement("img");
|
||||
imgEl.className = "wfm-gallery-thumb-img";
|
||||
imgEl.loading = "lazy";
|
||||
imgEl.src = API.thumb(img.path);
|
||||
imgEl.alt = img.filename;
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
card.appendChild(imgEl);
|
||||
|
||||
card.addEventListener("click", () => selectImage(img));
|
||||
card.addEventListener("dblclick", () => openLightbox(img));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 選択画像 / Positive・Negative表示 ─────────────────────────
|
||||
|
||||
function styleNameFromFilename(filename) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
async function selectImage(img) {
|
||||
state.selectedImage = img;
|
||||
document.querySelectorAll("#wfm-stylecatalog-grid .wfm-gallery-thumb-card").forEach(el => {
|
||||
el.classList.toggle("selected", el.dataset.path === img.path);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-stylecatalog-selected-name").textContent = img.filename;
|
||||
const preview = document.getElementById("wfm-stylecatalog-selected-preview");
|
||||
preview.innerHTML = "";
|
||||
const previewImg = document.createElement("img");
|
||||
previewImg.src = API.serveImage(img.path);
|
||||
previewImg.alt = img.filename;
|
||||
previewImg.title = "Double-click to enlarge";
|
||||
previewImg.style.cssText = "max-width:100%;max-height:100%;object-fit:contain;cursor:zoom-in;";
|
||||
previewImg.addEventListener("dblclick", () => openLightbox(img));
|
||||
preview.appendChild(previewImg);
|
||||
|
||||
const positiveText = document.getElementById("wfm-stylecatalog-positive-text");
|
||||
const negativeText = document.getElementById("wfm-stylecatalog-negative-text");
|
||||
const copyPositiveBtn = document.getElementById("wfm-stylecatalog-copy-positive-btn");
|
||||
const copyNegativeBtn = document.getElementById("wfm-stylecatalog-copy-negative-btn");
|
||||
const selectStyleBtn = document.getElementById("wfm-stylecatalog-select-style-btn");
|
||||
const loadGenuiBtn = document.getElementById("wfm-stylecatalog-load-genui-btn");
|
||||
const openMetadataBtn = document.getElementById("wfm-stylecatalog-open-metadata-btn");
|
||||
|
||||
positiveText.textContent = "";
|
||||
negativeText.textContent = "";
|
||||
copyPositiveBtn.disabled = true;
|
||||
copyNegativeBtn.disabled = true;
|
||||
selectStyleBtn.disabled = false; // ファイル名からのStyle選択はメタデータ抽出に依存しない
|
||||
loadGenuiBtn.disabled = false;
|
||||
openMetadataBtn.disabled = false;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
const meta = await extractAllMetadata(file);
|
||||
|
||||
const positive = meta?.positives?.[0] || "";
|
||||
const negative = meta?.negatives?.[0] || "";
|
||||
|
||||
if (!positive && !negative) {
|
||||
positiveText.textContent = t("stylecatalogNoPromptFound");
|
||||
} else {
|
||||
positiveText.textContent = positive;
|
||||
negativeText.textContent = negative;
|
||||
copyPositiveBtn.disabled = !positive;
|
||||
copyNegativeBtn.disabled = !negative;
|
||||
}
|
||||
} catch (e) {
|
||||
positiveText.textContent = t("stylecatalogNoPromptFound");
|
||||
}
|
||||
}
|
||||
|
||||
// gallery-tab.jsのライトボックスと同じCSSクラスを使う(ESCで閉じる処理もgallery-tab.js側の
|
||||
// グローバルkeydownリスナーが共通で担う)。
|
||||
function openLightbox(img) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "wfm-gallery-lightbox";
|
||||
overlay.innerHTML = `
|
||||
<div class="wfm-gallery-lightbox-inner">
|
||||
<img src="${API.serveImage(img.path)}" class="wfm-gallery-lightbox-img" alt="${escapeHtml(img.filename)}">
|
||||
<div class="wfm-gallery-lightbox-footer">${escapeHtml(img.filename)}</div>
|
||||
<button class="wfm-gallery-lightbox-close">×</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay || e.target.classList.contains("wfm-gallery-lightbox-close")) {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function copyText(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = el?.textContent || "";
|
||||
if (!text.trim()) return;
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => showToast(t("imagePromptCopiedToast"), "success"))
|
||||
.catch((e) => showToast(t("errorWithMsg", e.message), "error"));
|
||||
}
|
||||
|
||||
function onSelectStyleClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
const name = styleNameFromFilename(img.filename);
|
||||
const ok = selectStyleByName(name);
|
||||
if (ok) {
|
||||
showToast(t("stylecatalogStyleSelected", name), "success");
|
||||
} else {
|
||||
showToast(t("stylecatalogNoMatch"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// カタログ画像は生成元の出力画像と同一バイト列で保存されているため、埋め込みワークフローも
|
||||
// そのまま残っている。Outputフォルダ内の元画像を探す必要なく、この画像から直接読み込める。
|
||||
async function onLoadGenUIClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
try {
|
||||
const data = await apiFetch(API.workflow(img.path));
|
||||
if (!data.has_workflow) {
|
||||
showToast(t("galleryNoEmbeddedWorkflow"), "warning");
|
||||
return;
|
||||
}
|
||||
const loaded = await loadWorkflowIntoEditor(data.workflow, img.filename);
|
||||
if (loaded !== false) {
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function onOpenMetadataClick() {
|
||||
const img = state.selectedImage;
|
||||
if (!img) return;
|
||||
try {
|
||||
const res = await fetch(API.serveImage(img.path));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], img.filename, { type: blob.type || "image/png" });
|
||||
await loadFileIntoMetadataTab(file);
|
||||
} catch (e) {
|
||||
showToast(t("errorWithMsg", e.message), "error");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,76 @@
|
||||
* Shared utilities
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// PNG metadata embedding (iTXt chunk)
|
||||
// ============================================
|
||||
// Embeds an arbitrary key/UTF-8-text pair into a canvas-generated PNG data URL as
|
||||
// an iTXt chunk, inserted right after IHDR — same insertion point/CRC approach as
|
||||
// ComfyUI's own workflow-embedding (web/comfyui/top_menu_extension.js's
|
||||
// embedWorkflowInPng), but iTXt instead of tEXt so non-ASCII text (Japanese notes,
|
||||
// prompts, etc.) round-trips correctly through this codebase's PNG chunk reader
|
||||
// (metadata-tab.js's readAllPNGTextChunks already decodes iTXt text as UTF-8;
|
||||
// tEXt text there is decoded as Latin-1, which would mangle non-ASCII payloads).
|
||||
|
||||
function _n2b(n) {
|
||||
return new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
function _joinBytes(...arrs) {
|
||||
const result = new Uint8Array(arrs.reduce((total, a) => total + a.byteLength, 0));
|
||||
arrs.reduce((offset, a) => { result.set(a, offset); return offset + a.byteLength; }, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
let _pngCrcTable = null;
|
||||
function _pngCrc32(data) {
|
||||
if (!_pngCrcTable) {
|
||||
_pngCrcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
_pngCrcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) crc = (crc >>> 8) ^ _pngCrcTable[(crc ^ data[i]) & 0xff];
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
function _dataUrlToBytes(dataUrl) {
|
||||
const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function _bytesToDataUrl(bytes, mime) {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return `data:${mime};base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
export function embedPngTextChunk(dataUrl, keyword, text) {
|
||||
const bytes = _dataUrlToBytes(dataUrl);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
const data = _joinBytes(
|
||||
new TextEncoder().encode(`iTXt${keyword}`),
|
||||
new Uint8Array([0, 0, 0, 0, 0]), // keyword-NUL, compression flag(0), compression method(0), empty lang tag+NUL, empty translated keyword+NUL
|
||||
new TextEncoder().encode(text),
|
||||
);
|
||||
const chunk = _joinBytes(_n2b(data.byteLength - 4), data, _n2b(_pngCrc32(data)));
|
||||
|
||||
// Insert after IHDR (8-byte signature + 4-byte length + 4-byte type + IHDR data + 4-byte CRC)
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const insertAt = ihdrDataLen + 20;
|
||||
const result = _joinBytes(bytes.subarray(0, insertAt), chunk, bytes.subarray(insertAt));
|
||||
return _bytesToDataUrl(result, "image/png");
|
||||
}
|
||||
|
||||
// HTMLエスケープ(属性値・テキストコンテンツ両方で安全に使える)
|
||||
export function escapeHtml(s) {
|
||||
return String(s)
|
||||
@@ -27,6 +97,61 @@ export function getSettings() {
|
||||
return readJsonStorage("wfm_settings");
|
||||
}
|
||||
|
||||
// AIバックエンド(Ollama/LM Studio/Lemonade)ごとのデフォルトURL
|
||||
export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
unsloth: "http://localhost:8888",
|
||||
};
|
||||
|
||||
export function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Eagle Auto-Save
|
||||
// ============================================
|
||||
|
||||
export function getEagleSettings() {
|
||||
const s = getSettings();
|
||||
return {
|
||||
url: s.eagleUrl || "http://localhost:41595",
|
||||
autoSave: !!s.eagleAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveToEagle(imageUrl, name, tags = [], fileInfo = null) {
|
||||
const eagle = getEagleSettings();
|
||||
if (!eagle.autoSave) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/eagle/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
eagleUrl: eagle.url,
|
||||
url: imageUrl,
|
||||
name,
|
||||
tags: ["wfm-comfyui", ...tags],
|
||||
// SVG はサーバー側でローカルパス解決(addFromPath)するために必要
|
||||
filename: fileInfo?.filename || "",
|
||||
subfolder: fileInfo?.subfolder || "",
|
||||
type: fileInfo?.type || "output",
|
||||
// comfyui-tosvg の Save SVG String など、絶対パスが既知の場合に渡す
|
||||
localPath: fileInfo?.localPath || "",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === "success") {
|
||||
console.log("[Eagle] Saved:", name);
|
||||
} else {
|
||||
console.warn("[Eagle] Save failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Eagle] Save error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索inputにオーバーレイXボタンを設定する。
|
||||
* @param {string} inputId - 検索inputのID
|
||||
|
||||
@@ -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,7 +1207,8 @@ 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"));
|
||||
|
||||
+862
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Style/Prompt ギャラリー用シードデータ投入スクリプト。
|
||||
|
||||
comfyui_prompt_gallery / prompt_builder_proto 形式の wildcard データ
|
||||
(pack: set: category: leaf: [tag_string] という4階層YAML + thumbnails/ 配下の対応画像)
|
||||
から、画像とタグ文字列の組が揃っているエントリだけを ws_style_prompt 用フォルダへコピーする。
|
||||
|
||||
- テキストのみのワイルドカード(画像が無いもの)は対象外
|
||||
- サムネイル画像はあってもYAML側にタグが無いもの(プレースホルダー画像等)も対象外
|
||||
- コピー先には画像と同名の .txt サイドカー(タグ文字列そのまま)を書き込む
|
||||
|
||||
Usage:
|
||||
python tools/import_style_prompt_seed.py --source <wildcard_dataディレクトリ> --dest <ws_style_promptディレクトリ>
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
|
||||
|
||||
def clean_tags(text: str) -> str:
|
||||
text = text.strip()
|
||||
text = re.sub(r"^[,\s]+|[,\s]+$", "", text)
|
||||
text = re.sub(r"\s*,\s*", ", ", text)
|
||||
return text
|
||||
|
||||
|
||||
def parse_yaml_leaves(yaml_path: Path) -> dict[str, str]:
|
||||
"""comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。
|
||||
「key:」行の直後が「- タグ文字列」で始まる行なら key をリーフとみなす。
|
||||
戻り値: {leaf_name(小文字): タグ文字列}
|
||||
"""
|
||||
leaves: dict[str, str] = {}
|
||||
lines = yaml_path.read_text(encoding="utf-8", errors="replace").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
trimmed = line.strip()
|
||||
if not trimmed or trimmed.startswith("#"):
|
||||
continue
|
||||
if not trimmed.endswith(":"):
|
||||
continue
|
||||
if i + 1 >= len(lines):
|
||||
continue
|
||||
next_trimmed = lines[i + 1].strip()
|
||||
if not next_trimmed.startswith("-"):
|
||||
continue
|
||||
key = trimmed[:-1].strip()
|
||||
tags = next_trimmed[1:].strip()
|
||||
if not tags or key.lower() == "skip":
|
||||
continue
|
||||
leaves[key.lower()] = clean_tags(tags)
|
||||
return leaves
|
||||
|
||||
|
||||
def import_category(cat_dir: Path, dest_root: Path) -> tuple[int, int, int]:
|
||||
"""1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)"""
|
||||
category = cat_dir.name
|
||||
yaml_files = sorted(cat_dir.glob("*.yaml")) + sorted(cat_dir.glob("*.yml"))
|
||||
|
||||
leaves: dict[str, str] = {}
|
||||
for yf in yaml_files:
|
||||
leaves.update(parse_yaml_leaves(yf))
|
||||
|
||||
copied = 0
|
||||
images_without_prompt = 0
|
||||
|
||||
# "thumbnails" と、preview違いの "thumbnails_option2"(例: alchemist.preview3.jpeg)の両方を対象にする
|
||||
for thumb_dir_name in ("thumbnails", "thumbnails_option2"):
|
||||
thumbnails_dir = cat_dir / thumb_dir_name
|
||||
if not thumbnails_dir.is_dir():
|
||||
continue
|
||||
for img_path in sorted(thumbnails_dir.rglob("*")):
|
||||
if not img_path.is_file() or img_path.suffix.lower() not in IMAGE_EXTS:
|
||||
continue
|
||||
# "alchemist.preview3.jpeg" のような二重拡張子にも対応するため、最初の "." より前をリーフ名候補にする
|
||||
leaf_candidate = img_path.name.split(".")[0].lower()
|
||||
tags = leaves.get(leaf_candidate)
|
||||
if tags is None:
|
||||
images_without_prompt += 1
|
||||
continue
|
||||
|
||||
rel_parts = img_path.relative_to(thumbnails_dir).parts
|
||||
# 先頭1階層(パック名。例: "ponyxl")は全カテゴリ共通で冗長なため省く
|
||||
dest_rel = Path(*rel_parts[1:]) if len(rel_parts) > 1 else Path(rel_parts[0])
|
||||
dest_path = dest_root / category / dest_rel
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.copy2(img_path, dest_path)
|
||||
dest_path.with_suffix(".txt").write_text(tags, encoding="utf-8")
|
||||
copied += 1
|
||||
|
||||
return copied, images_without_prompt, len(leaves)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", required=True, help="wildcard_data ディレクトリ")
|
||||
parser.add_argument("--dest", required=True, help="投入先 ws_style_prompt ディレクトリ")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = Path(args.source)
|
||||
dest = Path(args.dest)
|
||||
if not source.is_dir():
|
||||
raise SystemExit(f"source not found: {source}")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
categories = [
|
||||
d for d in sorted(source.iterdir())
|
||||
if d.is_dir() and (d / "thumbnails").is_dir()
|
||||
]
|
||||
if not categories:
|
||||
raise SystemExit(f"No category with a thumbnails/ subfolder found under {source}")
|
||||
|
||||
total_copied = 0
|
||||
total_skipped = 0
|
||||
for cat_dir in categories:
|
||||
copied, skipped, leaf_total = import_category(cat_dir, dest)
|
||||
print(f"[{cat_dir.name}] copied={copied} images_without_prompt={skipped} yaml_leaves={leaf_total}")
|
||||
total_copied += copied
|
||||
total_skipped += skipped
|
||||
|
||||
print(f"\nDone. {len(categories)} categories, {total_copied} images copied, {total_skipped} skipped (no matching prompt).")
|
||||
print(f"Destination: {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1200
-56
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user