Compare commits

...
56 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 4.6 b20f46dc93 feat: show CivitAI image as preview fallback when no local file exists
When the backend preview download fails (server-side network restriction etc.),
loadPreviewImage() now falls back to civitaiCache[sha256].images[0] so the
card and side panel always show the CivitAI image after fetching.
fetchCivitaiForModel() also refreshes the grid and side panel regardless of
preview_saved, using the CivitAI URL directly when no local file was saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:32:20 +09:00
ketle-manandClaude Sonnet 4.6 14c538eb55 docs: v0.3.15 README and DEVLOG update
- README: version badge 0.3.14 → 0.3.15, Sample Workflows section,
  CivitAI auto-preview note, Create Tags in TOOLS pane, v0.3.15 changelog
- DEVLOG: add 2026-05-20 v0.3.15 entry covering all three changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:35:51 +09:00
ketle-manandClaude Sonnet 4.6 ef0017a0fc docs: update help text for CivitAI auto-preview and Create Tags
- helpModels5 (EN/JA/ZH): note that preview is auto-downloaded when missing
- helpAi3 (EN/JA/ZH): add Create Tags / タグ作成 / 标签生成 to TOOLS task list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:33:37 +09:00
ketle-manandClaude Sonnet 4.6 19e77a00db feat: add Create tags option to TOOLS pane VLM task selector
Add "Create tags" task to the VLM dropdown in both the SPA AI TOOL tab
and the Library panel A tab. Sends a prompt that asks the VLM to output
comma-separated English tags describing the image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:31:38 +09:00
ketle-manandClaude Sonnet 4.6 826988cc0b feat: auto-save CivitAI preview image for models without preview
When fetching CivitAI info (individual or batch), if the model has no
preview image, automatically download the first CivitAI image and save
it as {model_stem}.preview.png next to the model file.

- civitai_service.py: add download_image() static method
- models_routes.py: auto-download preview in handle_civitai_fetch and
  handle_civitai_batch; batch summary includes preview_saved count
- models-tab.js: refresh side panel and grid on preview_saved=true;
  batch done toast shows "+N preview" when previews were saved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:16:16 +09:00
ketle-manandClaude Sonnet 4.6 28bfbcda6b docs: add Sample Workflows section to README
Explains that workflows/ contains sample workflows and that some may
require additional custom nodes installable via ComfyUI Manager or git clone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:54:18 +09:00
ketle-manandClaude Sonnet 4.6 6eef19e656 feat: add sample workflow files for distribution
Add workflows/ directory with 13 sample workflows (JSON + PNG thumbnails)
covering SD1.5, SDXL, DWPose, face detailer, and image editing presets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:46:51 +09:00
ketle-manandClaude Sonnet 4.6 646721a5f4 docs: update DEVLOG for AI TOOL tab English label fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:54:47 +09:00
ketle-manandClaude Sonnet 4.6 4c44215f70 fix: English-only static labels for AI TOOL tab (SPA + Library panel)
- Replace all Japanese-hardcoded labels in templates/index.html AI TOOL section with English
- Replace all Japanese-hardcoded labels in node_sets_menu.js AI tab (HTML + JS toast/status strings)
- Rename VLM subtab to TOOLS in Library panel subtab list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:52:20 +09:00
ketle-manandClaude Sonnet 4.6 66f46af8b6 v0.3.14: AI TOOL tab 3-pane layout + RAW JSON color customization
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:34:17 +09:00
ketle-manandClaude Sonnet 4.6 95266a20b8 v0.3.13: AI tab (Translation, VLM, Settings) for SPA and Library panel
- Add A tab to SPA (templates/index.html) with Translation, VLM, Settings sub-tabs
- Add new static/js/ai-tab.js module (Ollama / LM Studio backends, full i18n via t())
- Add A tab to Workflow Studio Library side panel (node_sets_menu.js)
- Settings shared between SPA and Library panel via localStorage (wfm_ai_settings)
- URL security: validate backend URL with new URL() before any network call
- Rename ollamaSettings to 'Ollama Settings (Prompt Tab)' in EN/JA/ZH to distinguish from AI tab
- Add AI tab help card and helpSidepanel16 to Help tab (index.html + i18n.js + app.js)
- Update docs/6_ws_library.png, README.md (v0.3.13), DEVLOG.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:42:40 +09:00
ketle-manandClaude Sonnet 4.6 d54234b18d v0.3.12: Raw JSON search, workflow analyzer extended model detection, README screenshots refresh
- GenerateUI: always-visible search bar in Raw JSON panel (match count, ↑/↓ nav, Escape/✕ clear, orange/yellow highlights, scroll sync)
- workflow_analyzer: CLIPLoader type-field detection (_CLIP_TYPE_TO_MODEL), subgraph node scanning, UnetLoaderGGUF support, NewBie/Ovis/HiDream/Wan/Cosmos/Lumina added
- README: screenshots replaced with 8 feature-focused images; v0.3.12 changelog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:46:59 +09:00
ketle-manandClaude Sonnet 4.6 4298fd9ea2 v0.3.11: Metadata tab extended loader support + Settings text size slider
- metadata-tab.js: add UnetLoaderGGUF/UNETLoaderGGUF as Diffusion Model
  and QuadrupleCLIPLoader as Text Encoder (was already in node_sets_menu.js
  for the I tab; now consistent across both Metadata tab and side panel)
- settings-tab.js: add applyTextareaFontSize() + Text Size section (10-28px
  slider that applies to all 8 prompt/chat textareas at once)
- app.js: apply saved textareaFontSize on page load alongside theme
- templates/index.html: update help text for Metadata and Settings tabs
- README/DEVLOG: v0.3.11 documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:03:10 +09:00
ketle-manandClaude Sonnet 4.6 b8b4a65881 v0.3.10: Information tab drag-to-canvas, extended node type support, bug fixes
- Add drag & drop from I tab (model/lora/Prompts) to ComfyUI canvas
  - model sub-tab: places CheckpointLoaderSimple / VAELoader / UNETLoader / CLIPLoader
  - lora sub-tab: drag individual LoRA → LoraLoader; Multiple LORA section → Lora Loader (LoraManager)
  - Prompts sub-tab: drag prompt → CLIPTextEncode with text pre-filled
  - Double-click also places nodes at canvas center
- Add UnetLoaderGGUF / UNETLoaderGGUF detection as Diffusion Model
- Add QuadrupleCLIPLoader (HiDream, 4-CLIP workflows) detection as Text Encoder
- Fix CLIPLoader widget name in MODEL_NODE_MAP (clip_name1 → clip_name)
- Fix Information tab preview area height (fixed 110px, prevents list compression)
- Update help (index.html, app.js, i18n.js EN/JA/ZH) with new drag features
- Update README and DEVLOG for v0.3.10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:25:37 +09:00
ketle-manandClaude Sonnet 4.6 0015638874 v0.3.9: side panel I tab + top bar icon fix
- Add Information (I) tab to ComfyUI side panel: drop PNG/WebP/JSON to
  inspect model, LoRA, and prompt metadata without leaving the canvas;
  sub-tabs: model / lora / Prompts with full-text preview and Copy button
- Shorten side panel tab labels to W / N / P / M / I with full name on hover
- Fix top bar icons invisible in newer ComfyUI: remove early-return guard
  that skipped SVG injection when Iconify had already converted the MDI
  class to an inline SVG; replace MutationObserver with simple rAF retry
- Update README, DEVLOG, help text (EN/JA/ZH), and index.html for v0.3.9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:48:37 +09:00
ketle-manandClaude Sonnet 4.6 a48d9c1437 v0.3.8: Metadata tab - data9 workflow support and unclassified prompt text type
- Extended extractPromptsFromNodeSet to resolve link sources from any STRING-output
  node (ComfySwitchNode, PrimitiveStringMultiline, etc.) without type restriction
- Added fallback for SamplerCustomAdvanced (no positive/negative inputs): return
  textMap contents as texts[] instead of null
- Extended step4 PrimitiveStringMultiline and step7 final fallback to search
  subgraph nodes via collectAllNodes
- Added "text" prompt type (no POS/NEG badge) for cases where polarity cannot be
  determined; buildPromptItem and handleFile updated accordingly
- Added metaPromptText i18n key (EN/JA/ZH)
- Updated README (v0.3.8, feature description) and DEVLOG

Supported in data9: flux2_fp8, flux2_klein_4b_distilled, ernie_image,
qwen_image_edit_2511, wan2_2_14B_animate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 00:32:49 +09:00
ketle-manandClaude Sonnet 4.6 48303e1452 docs: update README and DEVLOG for v0.3.7
README:
- Bump version badge to 0.3.7
- Update Metadata Tab feature section: subgraph support, expanded
  format list, updated prompt extraction description
- Add v0.3.7 changelog entry

DEVLOG:
- Append i18n/HTML text update notes to v0.3.7 entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:05:56 +09:00
ketle-manandClaude Sonnet 4.6 b0171add24 docs: update Metadata tab format note and help text (EN/JA/ZH)
- metaFormatTodo: replace "more formats planned" with note that
  Flux.2 / Qwen-Image / Z-Image subgraph workflows are now supported
- helpMetadata5: list Flux.2 / Qwen-Image / Z-Image explicitly
- Update static fallback text in index.html accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:03:17 +09:00
ketle-manandClaude Sonnet 4.6 7958ad1276 v0.3.7: Metadata tab support for Flux2/Qwen/Z-Image subgraph workflows
- Add extractPromptsFromNodeSet() to handle CLIPTextEncode+KSampler in
  subgraphs (definitions.subgraphs) used by official ComfyUI templates
- Add extractMarkdownNoteModels() to parse **section** → - [model](url)
  from MarkdownNote nodes as fallback model extraction
- Add PrimitiveStringMultiline prompt support (flux2-klein workflows)
- Fix extractCheckpoints/extractVAEs to use collectAllNodes() so
  subgraph loaders are included
- Restructure extractPromptsLiteGraph into 7-stage fallback chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:09:06 +09:00
ketle-manandClaude Sonnet 4.6 fa62684b2e v0.3.6: Metadata tab for PNG/WebP/JSON model and prompt extraction
- Add Metadata tab (between Prompt and Gallery) with drag-and-drop support
- Extract checkpoint, VAE, diffusion model, text encoder, LoRA from PNG/WebP/JSON
- Extract positive/negative prompts with SDXL Prompt Styler support
- One-click transfer to GenerateUI and Prompt tab presets
- Support ComfyUI LiteGraph, API format, SD WebUI, Fooocus metadata
- Add i18n (EN/JA/ZH) for all new UI elements and help card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:49:38 +09:00
ketle-manandClaude Sonnet 4.6 8fcc4af262 fix: correct PublisherId to statsu for Registry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:49:52 +09:00
ketle-manandClaude Sonnet 4.6 33f1d22e30 ci: use official Comfy-Org/publish-node-action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:46:57 +09:00
ketle-manandClaude Sonnet 4.6 184da84718 ci: pass registry token via env variable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:40:22 +09:00
ketle-manandClaude Sonnet 4.6 e3aeec8022 ci: pipe N to skip tracking prompt in publish workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:39:04 +09:00
ketle-manandClaude Sonnet 4.6 c444db45e6 ci: add GitHub Actions workflow for Registry publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:37:33 +09:00
ketle-manandClaude Sonnet 4.6 a1613bd1e8 chore: bump version to 0.3.5 for Registry publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:28:05 +09:00
ketle-manandClaude Sonnet 4.6 30162789b8 docs: add thumbnail and update Registry icon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:21:44 +09:00
ketle-manandClaude Sonnet 4.6 1cd3fb3fff v0.3.5: Feeder subtab for comfyui-image-feeder + workflow analysis improvements
- Add Feeder subtab to GenerateUI: ImageFeeder node selector, all node parameters,
  folder tree, image grid with checkbox selection, preview panel, presets,
  Run/Stop loop with After gen modes (Loop / Increment / Fixed)
- Run loop captures image_loop_node_sync WebSocket messages to auto-update Index;
  uses right-pane seed settings for KSampler (fixes same-image-repeated bug)
- Improve analyzeWorkflow: BFS CONDITIONING chain expansion (5 iterations),
  CLIPTextEncodeSDXL/Refiner, SDXLPromptStyler/Advanced, KSamplerAdvanced noise_seed,
  TextEncodeQwenImageEditPlus, PrimitiveStringMultiline, Power Lora Loader (rgthree),
  Checkpoint Loader (WAS) with space in class name
- Fix KSamplerAdvanced seed key via data-seed-key attribute in editor
- Add Help card for Feeder subtab with EN/JA/ZH i18n
- Update README to v0.3.5, add Feeder subtab section and changelog entry
- Update DEVLOG with full implementation notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:11:25 +09:00
ketle-manandClaude Sonnet 4.6 5b087713b9 v0.3.4: Checkpoint Batch folder-tree dropdown, Pause/Resume, Settings side-by-side layout
- Checkpoint Batch: replace Include/Exclude text inputs with folder-tree dropdown
  - Folder-level checkboxes with indeterminate state for partial selection
  - Expand/collapse folders with ▶; supports any subfolder depth
  - Filter search, All / None quick-select buttons
- Checkpoint Batch: add Pause / Resume button (suspends between models)
- Checkpoint Batch: Stop while paused now immediately exits the loop
- GenerateUI Settings tab: KSampler and Latent Image side by side (50% each)
- Help tab: update gen-6, add gen-11 (batch), fix gallery-5~11 i18n gap (5-item shift)
- i18n: add helpGen11, helpGallery11; update helpGallery5-10 across EN/JA/ZH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:41:57 +09:00
ketle-manandClaude Sonnet 4.6 6a2da8e4e9 docs: update DEVLOG v0.3.3 with subdirectory support and ImpactWildcardEncode fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:43:52 +09:00
ketle-manandClaude Sonnet 4.6 54fc57344a feat: wildcard subdirectory support
- wildcard_service.py: rglob recursive scan; _safe_path allows
  forward-slash separated relative paths (path traversal still blocked);
  save_file auto-creates parent directories; all responses include
  dir and wc_name fields (e.g. wc_name="color/animals").
- prompt-tab.js: file list and file picker group files by directory with
  a collapsible directory header; insert uses __wc_name__ (e.g.
  __color/animals__); editor name input accepts "folder/name" paths;
  save validation checks each path component individually.
- index.html: update editor name input placeholder to "name or folder/name".
- main.css: add .wfm-wc-dir-header, .wfm-wc-file-item--sub,
  .wfm-wc-picker-dir, .wfm-wc-picker-item--sub; add -webkit-user-select
  for Safari compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:41:46 +09:00
ketle-manandClaude Sonnet 4.6 1cc0e94724 Fix: ImpactWildcardEncode validation error in GenerateUI
- comfyui-workflow.js: validate COMBO widget values against object_info
  choices; fall back to first valid choice when stored value (e.g.
  "Select Wildcard 🟢 Full Cache") is not in the current options list.
  Prevents "Prompt outputs failed validation" with Impact Pack nodes.
- comfyui-workflow.js: recognize ImpactWildcardEncode/ImpactWildcardProcessor
  as prompt nodes in analyzeWorkflow (role detection via sampler refs);
  use wildcard_text as the editable text field (textKey).
- comfyui-editor.js: use node's textKey (defaulting to "text") when
  applying and syncing prompt fields, so ImpactWildcardEncode edits
  update wildcard_text instead of the non-existent text field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:27:53 +09:00
ketle-manandClaude Sonnet 4.6 823f1ebd54 v0.3.3: Wildcard support panel & Impact Pack integration
- Add wildcard input toolbar to Prompt tab (right column) with one-click
  syntax buttons ({|}, {n$|}, __|__, <lora:>, etc.) and selection wrapping
- Add wildcard file manager: create/edit/delete .txt/.yaml files stored in
  user/default/Workflow-Studio/wildcard/
- Tab-ify Prompt tab center pane (Presets / Preset Manager toggle)
- New wildcard CRUD API (GET/POST /api/wfm/wildcards/*)
- New Wildcard Integration section in Settings tab: auto-detects
  ComfyUI-Impact-Pack and creates a directory junction (Windows) or symlink
  (other OS) between WFS wildcard dir and Impact Pack wildcards dir; existing
  files are migrated automatically; link can be removed via UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:02:10 +09:00
ketle-manandClaude Sonnet 4.6 0886ea6e2b v0.3.2: Checkpoint Batch for GenerateUI & Seed layout fix
- Add Checkpoint Batch panel to GenerateUI right column: checkbox ON/OFF,
  include/exclude subfolder filters (comma-separated), real-time model count
  preview, amber progress bar with model name and index display
- Refactor handleGenerate into _coreGenerate (throws) + _runBatchGenerate loop;
  Stop button now aborts both single generation and batch loop
- Fix Seed row layout: seed input and mode selector now stacked vertically
  so neither is clipped in narrow right panels
- Update Help tab, DEVLOG, README for v0.3.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:14:55 +09:00
ketle-manandClaude Sonnet 4.6 226fc885d7 v0.3.1: Gallery folder/file operations, tree state persistence, workflow auto-save
- Gallery: add folder create/delete and file delete/move (single & multi-select)
- Gallery: preserve folder tree expanded state across operations
- Gallery: improve workflow metadata extraction (workflow > prompt > gallery_metadata.json)
- Generate tab: auto-save workflow metadata to gallery on image generation
- Help tab: update Gallery section to reflect new v0.3.1 features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:38:33 +09:00
ketle-manandClaude Sonnet 4.6 8d48c2276f v0.3.0: Cross-type group filter & sidebar group display fix
- models-tab.js: group filter dropdown now shows groups from all model
  types with [TypeLabel] prefix; selecting a group auto-switches type
- node_sets_menu.js: fix TypeError in renderModelGroups caused by v0.2.9
  per-type group format; add type label badge to each group header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:23:50 +09:00
ketle-manandClaude Sonnet 4.6 918ab54004 v0.2.9: Model Enable/Disable, per-type groups, multi-select & bulk delete
- Toggle model visibility by renaming file extension (.disabled suffix)
  per card (⏸ button), per group (Enable All / Disable All), or via
  status filter (All / Enabled / Disabled)
- Groups are now scoped per model type; old flat groups auto-migrated
- Multi-select mode with bulk action bar: add/remove from groups,
  permanently delete model files + preview + sidecar (.json/.info)
- Fix: subdirectory model disable path-doubling error
- Fix: Hypernetwork tab showing C/O/M/B from new ComfyUI COMBO format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 10:11:29 +09:00
ketle-manandClaude Sonnet 4.6 f25c042c39 v0.2.8: Move data dir to user/default/Workflow-Studio, add export/import
- py/config.py: DATA_DIR now resolves to ComfyUI/user/default/Workflow-Studio/
  with fallback to data/ when user/default/ does not exist
- py/routes/settings_routes.py: add GET /api/wfm/settings/export and
  POST /api/wfm/settings/import endpoints for full data bundle backup
- static/js/settings-tab.js: add Data Management section (export/import UI)
- static/js/i18n.js: add 8 new keys in EN/JA/ZH
- templates/index.html, static/js/app.js: add helpSettings8/9 to Help tab
- README.md, DEVLOG.md: updated for v0.2.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:11:40 +09:00
ketle-manandClaude Sonnet 4.6 1c9ec7ac19 Fix: Gallery tab auto-selects root folder on load
Previously the gallery showed "Select a folder to browse images."
until the user manually clicked a folder. Now the root folder is
automatically selected when the folder tree loads.

Also update docs/screenshot_gallery.png and screenshot_settings.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:09:12 +09:00
ketle-manandClaude Sonnet 4.6 817b76ed5d Merge feature/gallery-tab into main (v0.2.7)
Gallery tab, Group UI unification across all tabs, Settings 2-column layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:57:23 +09:00
ketle-manandClaude Sonnet 4.6 34c6e8d49e v0.2.7: Gallery tab, Group UI unification, Settings 2-column layout
- Gallery tab: image browser with Thumbnail/Table views, multi-select,
  server-side group/tag/favorite filtering, group rename support,
  os.scandir() + mtime folder cache for 6,000+ image libraries
- Group UI unified across Workflow, Nodes, Gallery tabs to match Models
  panel pattern (Current Groups / Add to Group / Create / Manage with Rename)
- Settings 2-column layout: Theme panel fixed on right (50% width, sticky);
  all other settings flow continuously on left without visual gap
- Gallery output directory configurable from Settings tab
- Help tab: Gallery tab documentation card added
- README and DEVLOG updated for v0.2.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:57:16 +09:00
ketle-manandClaude Sonnet 4.6 f9f83ed9b2 Settings: 2-column layout with Theme panel fixed on the right
- Left column (480px): Language, Workflow folder, Gallery output, ComfyUI,
  Ollama, Default workflow, Eagle, Save button
- Right column (flex:1, sticky top): Theme selector + customizer only,
  max-height with overflow-y:auto so it stays visible while scrolling left col
- Add .wfm-settings-layout, .wfm-settings-left-col, .wfm-settings-right-col
  CSS classes; remove max-width:700px constraint from container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:56:29 +09:00
ketle-manandClaude Sonnet 4.6 a6100add6c Help: add Gallery tab documentation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:52:04 +09:00
ketle-manandClaude Sonnet 4.6 5108f710a6 Unify group UI labels across Workflow, Nodes, Gallery tabs
- Workflow Groups tab: remove redundant "Groups" label and workflow name
  (already shown in side panel header); start directly with Current Groups
- Nodes Groups tab: rename "Groups" section header to "Current Groups"
- Gallery Group tab button: rename "Group" → "Groups"
- Gallery section titles: remove text-transform:uppercase + letter-spacing,
  match Models style (13px font-weight:600, title-case labels)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:49:25 +09:00
ketle-manandClaude Sonnet 4.6 6756708f34 Unify group UI to Models standard across Nodes and Workflow tabs
Nodes (renderSideGroups):
- Replace "Remove from Group" text buttons with × icon buttons
- Rename "Create Group" button to "Create", create also assigns node to new group
- Add "Manage Groups" section with Rename (✎) and Delete (×) buttons
- Use el.querySelector() scope to avoid global ID conflicts on re-render

Workflow (renderSideGroup — new, replaces sidePanelGroupUpdate + refreshGroupSelects):
- Rewrite Group tab as fully dynamic JS-rendered panel matching Models layout
- Sections: Current Groups (× remove), Add to Group, Create New Group, Manage Groups
- Create also assigns current workflow to new group
- _refreshGroupFilter() handles toolbar filter select updates separately
- Remove all static HTML from Group tab in index.html (JS-rendered container only)
- Remove old event listeners (wfm-group-add-btn, assign-btn, remove-btn, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:13:40 +09:00
ketle-manandClaude Sonnet 4.6 e0cc44e56d Gallery: unify group UI to Models standard + performance improvements
Backend:
- gallery_metadata.py: add rename_group() + get_group_member_set() for fast server-side filtering
- gallery_service.py: replace stat()/iterdir() with os.scandir() (single syscall for size+mtime),
  add folder-level in-memory cache (invalidates on folder mtime change or 30s TTL),
  add server-side group_filter to list_images() using set lookup (eliminates full transfer on group filter)
- gallery_routes.py: add PUT /wfm/gallery/groups/{name} rename endpoint,
  pass group_filter param to list_images()

Frontend:
- gallery-tab.js: group filter now server-side (no more full JSON transfer on group switch),
  replace static group panel with renderDetailGroup() — Models-style dynamic UI:
  Current Groups (×remove), Add to Group (assign select), Create Group (creates+assigns),
  Manage Groups (rename via prompt + delete), add API.groupRename, add escapeHtml()
- templates/index.html: Gallery Group tab replaced with JS-rendered container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:55:07 +09:00
ketle-manandClaude Sonnet 4.6 3ce51dd5f6 Gallery: remove tree toggle, add group filter, multi-select with bulk operations
- Remove folder tree collapse toggle (panel is always visible)
- Remove output path display from tree panel
- Add group filter select in toolbar (client-side filtering)
- Add multi-select: Ctrl+click on thumbnails and table rows (amber highlight)
- Add bulk operation bar: Deselect All, Add to Group, Favorite All, Unfavorite All
- Move Fav column to leftmost position in table view (matches other tabs)
- Extract toggleFavoriteInPlace() to avoid full re-render on favorite toggle
- Remove CSS transition from tree panel (performance improvement)
- renderGroupSelect() now also populates bulk group select and group filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:08:04 +09:00
ketle-manandClaude Sonnet 4.6 43d7e03d72 Security: fix path traversal, PNG chunk OOM, JPEG read limit in gallery
- GalleryService._check_path_allowed(): resolve() + relative_to(allowed_root)
  blocks access to any path outside the configured output directory
- allowed_root set on first list_folder_tree() call; updated on settings change
  via settings_routes -> gallery_service.update_output_root()
- _read_png_metadata: skip chunks > 32MB to prevent OOM on crafted PNGs
- _read_jpeg_metadata: cap file read at 64MB; stop after first COM segment
- serve_image / get_image_metadata / list_images / extract_workflow:
  all resolve() paths and check against allowed_root before opening

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:01:49 +09:00
ketle-manandClaude Sonnet 4.6 370bc6aa6f Gallery: move tree toggle to fixed left bar, workflow-only metadata view
- Folder tree toggle button moved to independent 20px bar at far left
  always visible regardless of tree panel state (panel hides/shows entirely)
- Metadata tab now shows workflow JSON only in full-height <pre> block
  with toolbar (status label + Copy Workflow button) at top
- Removed old metadata key/value row rendering, replaced with renderWorkflowJson()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:49:39 +09:00
ketle-manandClaude Sonnet 4.6 ae85378e34 Fix gallery: tree collapse button visibility, split Metadata into separate tab
- Tree panel: overflow:visible on panel, ov:hidden only on tree scroll area
  so the toggle button remains clickable at 32px collapsed width
- Detail panel: Metadata moved from Info tab into its own 'Metadata' tab
  (Info / Metadata / Group — 3 tabs)
- JS toggleTreePanel sets explicit max-width to prevent layout bleed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:36:27 +09:00
ketle-manandClaude Sonnet 4.6 8bdb487a83 Fix Gallery tab: settings output path, tree toggle, card favorites, remove thumb filename
1. Output folder setting moved to Settings tab (Workflow Data Folder section)
   - New API: GET/POST /api/wfm/settings/output-dir
   - Auto-detects ComfyUI default output via folder_paths
   - gallery-tab.js reads from settings API on init
   - Settings change dispatches wfm-output-dir-changed event to live-reload tree

2. Fix folder tree collapse/expand toggle
   - Replaced CSS .collapsed class with direct JS style control
   - Expand now correctly restores tree, output label, and title

3. Favorite toggle moved to thumbnail card (hover button, top-right)
   - Removed favorite button from detail panel Info tab
   - Added ★/☆ toggle button on both thumb cards and table rows
   - Detail panel no longer has updateFavBtn / toggleFavorite logic

4. Remove filename text from thumbnail cards (shown in tooltip on hover)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:15:26 +09:00
ketle-manandClaude Sonnet 4.6 cc9a961a50 Add Gallery tab: 3-column image browser for ComfyUI output folder (Phase 1)
- New Gallery tab with folder tree / image grid / detail panel layout
- Backend: gallery_service.py (folder tree, image list, PNG metadata extraction, groups)
- Backend: gallery_metadata.py (persistent metadata store for tags, memo, favorites, groups)
- Backend: gallery_routes.py (REST API endpoints)
- Frontend: gallery-tab.js (folder tree, thumbnail/table view, lightbox, tag/memo/group management)
- Frontend: gallery-tab.css (3-column layout, thumb cards, lightbox, detail panel)
- i18n: EN/JA/ZH keys for Gallery tab
- PNG embedded workflow extraction with clipboard copy support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:33:47 +09:00
ketle-manandClaude Sonnet 4.6 40955f2822 Improve UI layout, fix badge persistence, add folder filter (v0.2.6)
- Fix workflow badge not saved/loaded after restart (workflow_service.py)
- Fix workflow badge filter buttons to show palette-defined badges with colors
- Make side panels always visible (remove toggle) in Workflow/Model/Node tabs
- Replace pagination with full scroll display in all three tabs
- Add folder (subdir) filter to Models tab toolbar
- Remove dir/ext badges from Model card view (visible in side panel)
- Resize thumbnail cards to 178x200px, fix grid layout crush with grid-auto-rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:12:31 +09:00
ketle-manandClaude Sonnet 4.6 de6da654ca Add sidebar Category/Package tabs, theme settings, and bug fixes (v0.2.5)
- Sidebar Nodes tab: add Category and Package sub-tabs with dropdown filtering
  - Category: filter nodes by top-level category via /object_info
  - Package: filter nodes by custom node package name (DualCLIPLoader/CLIPLoader fallback)
- Sidebar theme settings: add gear button to customize panel colors (bg, text, border, etc.) with live preview; saved to localStorage
- Sidebar Workflows: hide .index.json from workflow list
- Sidebar Models By Type: fix TextEncoder not appearing (use DualCLIPLoader→CLIPLoader fallback for clip_name1)
- Workflow tab: hide .index.json from workflow list
- Help tab: update Workflow Studio Library section with new features
- Remove unused buildDropdown function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:07:49 +09:00
ketle-manandClaude Sonnet 4.6 e7fdfc41ad Redesign GenerateUI tab layout: 3-tab structure with inline Raw JSON (v0.2.4)
- 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 preview and direct editing from any tab
- Input tab: Prompt (top) + Image (bottom) stacked vertically in left column
- Model tab: model selectors with filter in left column
- Settings tab: KSampler (top) + LatentImage (bottom) stacked vertically in left column
- Raw JSON widget shared across tabs via DOM appendChild on tab switch
- Right column slimmed to 280px: Generate/Seed/Results only
- Docs: DEVLOG, README, i18n help keys, index.html help tab updated for v0.2.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:01:45 +09:00
ketle-manandClaude Sonnet 4.6 fe47460e00 Unify badge system, add GenUI Model button, expand sidebar Models tab (v0.2.3)
- Badge system: Workflow and Models tabs share a single palette (wfm_models_badge_palette); workflow auto-analysis model type system removed in favor of user-defined free-label badges
- GenUI Model button: apply models directly to GenerateUI's current workflow from detail modal and side panel (Checkpoint/LoRA/VAE/ControlNet/UNET/TextEncoder)
- Sidebar Models tab: All / Favorites / Groups / By Type sub-tabs added to Workflow Studio Library
- Sidebar All views: star (★) shown for favorites in Workflows and Nodes All sub-tabs
- Sidebar panel width: 280px → 310px
- Docs: DEVLOG, README, i18n help keys, index.html help tab updated for v0.2.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:12:28 +09:00
85 changed files with 33114 additions and 1018 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Publish to Comfy Registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+1730
View File
File diff suppressed because it is too large Load Diff
+250 -33
View File
@@ -5,29 +5,25 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.2.2-green)
![Version](https://img.shields.io/badge/version-0.3.16-green)
## Screenshots
| Workflow Tab | GenerateUI Tab |
| Workflow Tab | Models Tab |
|:---:|:---:|
| ![Workflow](docs/screenshot_workflow.png) | ![GenerateUI](docs/screenshot_generate.png) |
| ![Workflow Tab](docs/1_workflowtab.png) | ![Models Tab](docs/2_modelstab.png) |
| Prompt Tab | Settings Tab |
| Prompt Input Assistance | Gen UI Feeder |
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| ![Prompt Input Assistance](docs/3_PromptInputAssistance.png) | ![Gen UI Feeder](docs/4_GenUI_feeder.png) |
| Nodes Tab | Models Tab |
| Top Bar | WS Library |
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| ![Top Bar](docs/5_topbar.png) | ![WS Library](docs/6_ws_library.png) |
| Help & Support Tab | |
| Library Information | Customize |
|:---:|:---:|
| ![Help](docs/screenshot_help.png) | |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
| ![Workflow Studio Library](docs/screenshot_wf_node_library.png) | ![ComfyUI Top Bar](docs/screenshot_comfyui_topbar.png) |
| ![Library Information](docs/7_library_Infomation.png) | ![Customize](docs/8_Customize.png) |
---
@@ -36,10 +32,10 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
### Workflow Tab
- **Thumbnail / Card / Table views** — switch between view modes to browse your workflow library
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
- **Tag-based filtering** — filter by base model (SD1.5, SDXL, etc.) and custom groups
- **Badge filtering** — filter by user-defined badges (free labels you assign to each workflow)
- **Search** — full-text search across workflow names and metadata
- **Side panel tabs** — Thumbnail preview, JSON viewer with syntax highlighting, and Group management
- **Batch analysis** — auto-detect checkpoint, model type, prompt, and I/O node counts
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
- **AI summary** — generate workflow descriptions using Ollama
- **Import / Export** — import workflows from files or clipboard, open in ComfyUI directly
- **Default view setting** — persist your preferred view mode (Thumbnail / Card / Table)
@@ -50,16 +46,33 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **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
- **Auto-generated parameter UI** — prompt, model, sampler, image, and other settings extracted from the workflow
### GenerateUI Tab (v0.3.5)
- **4-tab layout** — Input / Model / Settings / Feeder tabs; Input, Model, and Settings each include a Raw JSON column on the right for instant preview and direct editing
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
- **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
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds
- **Raw JSON editor** — view and edit the API-format JSON with syntax highlighting
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
- **Checkpoint Batch** — enable via checkbox in the right panel to sequentially generate with every checkpoint model; select checkpoints by folder from the dropdown (check a folder to select all its files, expand with ▶ for individual file selection, supports any subfolder depth); Filter input to search; All / None buttons for quick selection; **Pause/Resume** suspends processing between models; Stop aborts after the current generation; amber progress bar tracks per-model progress
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion; improved analysis covers SDXL multi-hop CONDITIONING chains, CLIPTextEncodeSDXL, SDXLPromptStyler, KSamplerAdvanced, and more
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
### Feeder subtab (v0.3.5)
Requires the **[comfyui-image-feeder](https://github.com/ketle-man/comfyui-image-feeder)** custom node.
- **ImageFeeder node control** — select the target node from a dropdown auto-populated from the loaded workflow; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection) and Apply to the workflow
- **Image library** — 3-pane layout: folder tree (left) browsing `user/default/image-loop-data/`, image grid with checkbox selection (center), preview panel with resolution and file size (right)
- **Selection management** — check individual images; All / None buttons for the current folder; selected files are reflected in `selected_files` on Apply
- **Presets** — save the current directory + selection as a named preset; load or delete presets (server-side persistence via `image-feeder-presets.json`)
- **Continuous Run loop** — Run / Stop buttons below the presets; **After gen** mode controls index behavior after each generation:
- **Loop** — advance index and wrap back to 0 when all images are exhausted (runs indefinitely)
- **Increment** — advance index and auto-stop when all images are consumed
- **Fixed** — always use the same index
- **Index sync** — after each generation the node returns `next_index` via WebSocket (`image_loop_node_sync`); the Index field updates automatically
- **Seed** — Run loop uses the right-pane seed setting (Random / Fixed / Increment / Decrement); the node's own Seed field only affects random sort order
### Prompt Tab
- **3-column layout** — AI Assistant, Presets editor, and Preset Manager displayed side-by-side
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
- **Image attachment** — attach reference images for vision-capable models
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
@@ -68,17 +81,50 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **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 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
### Metadata Tab (v0.3.8)
- **3-column layout** — Drop zone (left) | Model info (center) | LoRA + Prompt (right)
- **File drop** — drop a ComfyUI-generated PNG / WebP or workflow JSON onto the drop zone (or click to open a file picker); PNG/WebP images are shown as a preview
- **Model extraction** — automatically extracts Checkpoint, VAE, Diffusion Model, and Text Encoder names from the workflow; supports both standard and subgraph-based workflows (Flux.2 Dev/Klein, Qwen-Image-Edit/2511/Layered, Z-Image Base/Turbo, Ernie Image, WAN2.2); node types covered: UNETLoader, UnetLoaderGGUF, UNETLoaderGGUF (e.g. HiDream GGUF), CLIPLoader, DualCLIPLoader, TripleCLIPLoader, QuadrupleCLIPLoader (e.g. HiDream 4-CLIP)
- **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
- **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 note** — supported formats and covered model types are always shown in the left column
### Settings Tab
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
- **Workflows directory** — configure which folder to scan for workflows
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
- **Eagle connection** — set Eagle API endpoint for auto-save
- **Ollama connection** — configure Ollama server URL
- **Default workflow** — set a workflow to auto-load on startup
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
- **Language** — English / Japanese / Chinese
### Gallery Tab (v0.3.1)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To... and Delete from the multi-select bar
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations (group, favorite, move, delete)
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
- **Favorites** — star images inline without reopening the detail panel
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
- **Output folder configurable** — set the scanned output folder from Settings tab
- **Performance** — folder-level mtime cache (30s TTL) for fast incremental refresh; tree expansion state preserved across folder operations
### Nodes Tab (v0.1.7)
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
@@ -87,28 +133,51 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
### Models Tab (v0.2.2)
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with sub-tab switching
### Models Tab (v0.2.3)
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding) with sub-tab switching
- **Thumbnail / Card / Table views** — switch between view modes with pagination (24 items per page)
- **Search & Filter** — full-text search, filter by tags, groups, and favorites
- **User-defined badges** — assign free-label badges to models; badge colors shared with the Workflow tab palette
- **Side panel tabs** — Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link; preview image is automatically downloaded and saved if none exists
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming; previews are auto-saved for models without one
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
- **Group management** — create, rename, delete groups and assign/remove models
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab)
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
- **Enable / Disable models** — hide models from ComfyUI by renaming the file extension (`.disabled` suffix); toggle per card (⏸ button), per group (Enable All / Disable All), or filter by status (All / Enabled / Disabled)
- **Multi-select & bulk operations** — enter selection mode to check multiple models; bulk action bar supports add/remove from groups and permanent file deletion (model file + preview images + sidecar files such as `.json` / `.info`)
### Workflow Studio Library (ComfyUI Side Panel) (v0.1.8)
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group
- **Nodes tab** — browse favorite nodes, node sets, and node groups
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
### AI TOOL Tab (v0.3.14)
- **3-pane layout** — Translation (40%) | TOOLS (40%) | Settings (20%); all panes always visible simultaneously; no sub-tab switching required
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
- **TOOLS pane (VLM)** — drop an image into the 110px drop zone, select a task (Describe Image / Create Prompt / Create Tags), and click Run to analyze with a vision model; result shown in the output area with a Copy button
- **Settings pane** — choose backend (Ollama / LM Studio), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
- **Backend support** — Ollama (`/api/generate` for text, `/api/tags` for model list); LM Studio OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio)
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
- **Tab layout (W / N / P / M / I / A)** — compact single-letter tabs with full name shown on hover
- **W — Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
- **N — Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
- **Category sub-tab** — dropdown to filter nodes by top-level category
- **Package sub-tab** — dropdown to filter nodes by custom node package name
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs)
- **P — Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP or workflow JSON in the side panel to view its metadata; supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
- **A — AI TOOL tab** — 3-pane layout (Translation | TOOLS | Settings) always visible; Translation and TOOLS (VLM) powered by Ollama or LM Studio; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
- **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** — LoRA names with `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
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
- **Double-click** — load workflows or place nodes without dragging
- **Search** — search within each sub-tab to quickly find items
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
### Help & Support Tab (v0.1.3)
- **Feature list** — overview of all features organized by tab
@@ -134,6 +203,22 @@ Restart ComfyUI after installation.
---
## Sample Workflows
Sample workflows are included in the `workflows/` folder. You can open them directly in ComfyUI via drag & drop, or load them from the Workflow tab.
> **Note:** Some sample workflows require additional custom nodes.
> If a node is shown as missing (red/unknown) after loading, install the required custom nodes via **ComfyUI Manager** or by cloning the repository from GitHub into your `ComfyUI/custom_nodes/` directory.
>
> ```bash
> cd ComfyUI/custom_nodes
> git clone <repository-url>
> ```
>
> After installation, restart ComfyUI to activate the new nodes.
---
## Usage
### Launch
@@ -168,7 +253,8 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
### Optional
- **[Ollama](https://ollama.com/)** — for AI chat assistant and translation features
- **[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)
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
---
@@ -185,6 +271,134 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### 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
@@ -282,7 +496,7 @@ ComfyUI-Workflow-Studio/
│ ├── settings_service.py # Settings persistence
│ └── png_extractor.py # PNG metadata extraction
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Metadata/Gallery/Nodes/Models/Settings/Help/AI)
├── static/
│ ├── favicon.svg # Browser tab icon (W+S Wave)
│ ├── css/main.css # Styles
@@ -290,11 +504,14 @@ ComfyUI-Workflow-Studio/
│ ├── app.js # App initialization & routing
│ ├── workflow-tab.js # Workflow browser
│ ├── generate-tab.js # Generation UI
│ ├── feeder-tab.js # Feeder subtab (ImageFeeder node control + image library)
│ ├── prompt-tab.js # AI assistant & presets
│ ├── metadata-tab.js # Metadata extraction & display (PNG/WebP/JSON)
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
│ ├── models-tab.js # Model browser & CivitAI integration
│ ├── ai-tab.js # AI TOOL tab (Translation | TOOLS/VLM | Settings — Ollama / LM Studio)
│ ├── comfyui-workflow.js # UI-to-API format conversion
│ ├── comfyui-editor.js # Dynamic parameter editor
│ ├── json-highlight.js # JSON syntax highlighting
@@ -302,7 +519,7 @@ ComfyUI-Workflow-Studio/
├── web/comfyui/
│ ├── top_menu_extension.js # ComfyUI menu bar integration
│ └── node_sets_menu.js # Workflow Studio Library side panel
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
```
---
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2100 900" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="2100" y2="900" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="50%" stop-color="#16213e"/>
<stop offset="100%" stop-color="#1a1a2e"/>
</linearGradient>
<linearGradient id="wStroke" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4a9eff"/>
<stop offset="100%" stop-color="#7b68ee"/>
</linearGradient>
<linearGradient id="sWave" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7b68ee"/>
<stop offset="100%" stop-color="#4a9eff"/>
</linearGradient>
<linearGradient id="textGrad" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#e0e0ff"/>
<stop offset="100%" stop-color="#b0b0d0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="2100" height="900" fill="url(#bg)"/>
<!-- Subtle grid -->
<line x1="0" y1="225" x2="2100" y2="225" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="450" x2="2100" y2="450" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="675" x2="2100" y2="675" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<!-- Decorative wave lines in background -->
<path d="M0 750 Q262 700 525 750 Q787 800 1050 750 Q1312 700 1575 750 Q1837 800 2100 750" stroke="#4a9eff" stroke-opacity="0.08" stroke-width="2" fill="none"/>
<path d="M0 780 Q262 730 525 780 Q787 830 1050 780 Q1312 730 1575 780 Q1837 830 2100 780" stroke="#7b68ee" stroke-opacity="0.06" stroke-width="2" fill="none"/>
<!-- W+S Logo (left side) -->
<g transform="translate(180, 180)">
<!-- W -->
<path d="M60 80 L120 280 L180 130 L240 280 L300 80" stroke="url(#wStroke)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- S wave -->
<path d="M80 340 Q140 300 200 340 Q260 380 320 340" stroke="url(#sWave)" stroke-width="18" stroke-linecap="round" fill="none"/>
</g>
<!-- Title text -->
<text x="720" y="380" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="120" font-weight="700" fill="url(#textGrad)" letter-spacing="-2">Workflow Studio</text>
<!-- Subtitle -->
<text x="720" y="470" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="42" fill="#8888aa" letter-spacing="1">Workflow Management &amp; Generation UI for ComfyUI</text>
<!-- Feature pills -->
<g font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#a0a0c0">
<rect x="720" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
<text x="820" y="559" text-anchor="middle" fill="#4a9eff">Workflows</text>
<rect x="940" y="530" width="200" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
<text x="1040" y="559" text-anchor="middle" fill="#7b68ee">GenerateUI</text>
<rect x="1160" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
<text x="1260" y="559" text-anchor="middle" fill="#4a9eff">AI Prompts</text>
<rect x="1380" y="530" width="180" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
<text x="1470" y="559" text-anchor="middle" fill="#7b68ee">Themes</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="100%" stop-color="#16213e"/>
</linearGradient>
<linearGradient id="wStroke" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4a9eff"/>
<stop offset="100%" stop-color="#7b68ee"/>
</linearGradient>
<linearGradient id="sWave" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7b68ee"/>
<stop offset="100%" stop-color="#4a9eff"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="400" height="400" rx="48" fill="url(#bg)"/>
<!-- Subtle grid pattern -->
<line x1="0" y1="100" x2="400" y2="100" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="200" x2="400" y2="200" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="300" x2="400" y2="300" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="100" y1="0" x2="100" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="200" y1="0" x2="200" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="300" y1="0" x2="300" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<!-- W letterform -->
<path d="M80 110 L140 290 L200 150 L260 290 L320 110" stroke="url(#wStroke)" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- S wave -->
<path d="M100 330 Q150 295 200 330 Q250 365 300 330" stroke="url(#sWave)" stroke-width="20" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+14 -4
View File
@@ -10,13 +10,22 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
# Directory paths
TEMPLATES_DIR = PLUGIN_DIR / "templates"
STATIC_DIR = PLUGIN_DIR / "static"
DATA_DIR = PLUGIN_DIR / "data"
# Default workflows directory: ComfyUI's user/default/workflows
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
# ComfyUI root is custom_nodes/../../ = two levels up
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
COMFYUI_ROOT = _COMFYUI_ROOT # exposed for external use
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
if _COMFYUI_USER_DEFAULT.is_dir():
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
else:
DATA_DIR = PLUGIN_DIR / "data"
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
# Default workflows directory: ComfyUI's user/default/workflows
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
if _COMFYUI_WORKFLOWS.is_dir():
@@ -59,3 +68,4 @@ NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
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"
+282
View File
@@ -0,0 +1,282 @@
"""
Gallery Routes - ギャラリータブ用APIエンドポイント
"""
import json
import mimetypes
import logging
from pathlib import Path
from aiohttp import web
from ..config import DATA_DIR
from ..services.gallery_service import GalleryService
logger = logging.getLogger(__name__)
_service = GalleryService(DATA_DIR)
def setup_routes(app: web.Application):
app.router.add_get("/wfm/gallery/folders", list_folders)
app.router.add_get("/wfm/gallery/images", list_images)
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
app.router.add_get("/wfm/gallery/image/serve", serve_image)
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
app.router.add_get("/wfm/gallery/groups", list_groups)
app.router.add_post("/wfm/gallery/groups", create_group)
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
# フォルダ・ファイル操作
app.router.add_post("/wfm/gallery/folder", create_folder_route)
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
app.router.add_post("/wfm/gallery/images/move", move_images_route)
# ──────────────────────────────────────────────────────────────
# フォルダツリー
# ──────────────────────────────────────────────────────────────
async def list_folders(request: web.Request) -> web.Response:
root = request.rel_url.query.get("root", "")
if not root:
return web.json_response({"error": "root parameter required"}, status=400)
try:
tree = _service.list_folder_tree(root)
return web.json_response(tree)
except Exception as e:
logger.error("list_folders error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# 画像一覧
# ──────────────────────────────────────────────────────────────
async def list_images(request: web.Request) -> web.Response:
folder = request.rel_url.query.get("folder", "")
search = request.rel_url.query.get("search", "")
sort_by = request.rel_url.query.get("sort", "date_desc")
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", "")
if not folder:
return web.json_response({"error": "folder parameter required"}, status=400)
try:
images = _service.list_images(
folder,
search=search,
sort_by=sort_by,
favorite_only=favorite_only,
tag_filter=tag_filter,
group_filter=group_filter,
)
return web.json_response({"images": images, "total": len(images)})
except Exception as e:
logger.error("list_images error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
async def get_image_meta(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.json_response({"error": "path required"}, status=400)
try:
meta = _service.get_image_metadata(path)
return web.json_response(meta)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def get_image_workflow(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.json_response({"error": "path required"}, status=400)
try:
wf = _service.extract_workflow_from_metadata(path)
if wf is None:
return web.json_response({"workflow": None, "has_workflow": False})
return web.json_response({"workflow": wf, "has_workflow": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def serve_image(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.Response(status=400)
img_path = _service.serve_image(path)
if img_path is None:
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(img_path))
return web.FileResponse(img_path, headers={
"Content-Type": content_type or "image/png",
"Cache-Control": "max-age=3600",
})
async def save_image_meta(request: web.Request) -> web.Response:
try:
body = await request.json()
img_path = body.get("path", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
data = {k: v for k, v in body.items() if k != "path"}
ok = _service.save_image_meta(img_path, data)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def toggle_favorite(request: web.Request) -> web.Response:
try:
body = await request.json()
img_path = body.get("path", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
new_val = _service.toggle_favorite(img_path)
return web.json_response({"favorite": new_val})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
async def list_groups(request: web.Request) -> web.Response:
return web.json_response({"groups": _service.list_groups()})
async def create_group(request: web.Request) -> web.Response:
try:
body = await request.json()
name = body.get("name", "").strip()
if not name:
return web.json_response({"error": "name required"}, status=400)
ok = _service.create_group(name)
if not ok:
return web.json_response({"error": "Group already exists"}, status=409)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def rename_group(request: web.Request) -> web.Response:
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
try:
old_name = request.match_info.get("name", "")
body = await request.json()
new_name = body.get("new_name", "").strip()
if not old_name or not new_name:
return web.json_response({"error": "old_name and new_name required"}, status=400)
ok = _service.rename_group(old_name, new_name)
if not ok:
return web.json_response({"error": "Rename failed (not found or name conflict)"}, status=400)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def delete_group(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
ok = _service.delete_group(name)
return web.json_response({"ok": ok})
async def add_to_group(request: web.Request) -> web.Response:
try:
name = request.match_info.get("name", "")
body = await request.json()
img_path = body.get("path", "")
ok = _service.add_to_group(img_path, name)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def remove_from_group(request: web.Request) -> web.Response:
try:
name = request.match_info.get("name", "")
body = await request.json()
img_path = body.get("path", "")
ok = _service.remove_from_group(img_path, name)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def list_group_images(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
images = _service.list_images_in_group(name)
return web.json_response({"images": images})
# ──────────────────────────────────────────────────────────────
# フォルダ・ファイル操作
# ──────────────────────────────────────────────────────────────
async def create_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/folder - 新規フォルダ作成"""
try:
body = await request.json()
parent = body.get("parent", "")
name = body.get("name", "").strip()
if not parent or not name:
return web.json_response({"ok": False, "error": "parent and name required"}, status=400)
result = _service.create_folder(parent, name)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_folder_route(request: web.Request) -> web.Response:
"""DELETE /wfm/gallery/folder - フォルダ削除"""
try:
body = await request.json()
folder = body.get("path", "")
if not folder:
return web.json_response({"ok": False, "error": "path required"}, status=400)
result = _service.delete_folder(folder)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/delete - 画像削除(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
if not paths:
return web.json_response({"deleted": [], "errors": ["paths required"]}, status=400)
result = _service.delete_images(paths)
return web.json_response(result)
except Exception as e:
return web.json_response({"deleted": [], "errors": [str(e)]}, status=500)
async def move_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/move - 画像移動(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
dest = body.get("dest", "")
if not paths or not dest:
return web.json_response({"moved": [], "errors": ["paths and dest required"]}, status=400)
result = _service.move_images(paths, dest)
return web.json_response(result)
except Exception as e:
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
+185 -5
View File
@@ -27,6 +27,10 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
# ── Model Metadata ─────────────────────────────────────────
@@ -61,9 +65,10 @@ async def handle_save_metadata(request: web.Request) -> web.Response:
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/models/groups"""
"""GET /api/wfm/models/groups?type=checkpoint"""
model_type = request.query.get("type", "")
try:
result = await asyncio.to_thread(_service.get_model_groups)
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
@@ -71,10 +76,17 @@ async def handle_get_groups(request: web.Request) -> web.Response:
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups"""
"""POST /api/wfm/models/groups
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
"""
try:
groups = await request.json()
result = await asyncio.to_thread(_service.save_model_groups, groups)
body = await request.json()
model_type = body.get("model_type", "")
groups = body.get("groups", {})
if not model_type:
return web.json_response({"error": "model_type required"}, status=400)
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
@@ -138,10 +150,24 @@ async def handle_civitai_fetch(request: web.Request) -> web.Response:
"message": "Model not found on CivitAI"
})
# Auto-set preview if none exists and CivitAI has images
preview_saved = False
if info.get("images"):
has_preview = await asyncio.to_thread(_service.find_preview_image, model_type, model_name)
if not has_preview:
image_url = info["images"][0]
preview_path = file_path.parent / (file_path.stem + ".preview.png")
preview_saved = await asyncio.to_thread(
CivitaiService.download_image, image_url, preview_path
)
if preview_saved:
logger.info("Auto-saved preview for %s from CivitAI", model_name)
return web.json_response({
"status": "ok",
"sha256": sha256,
"civitai": info,
"preview_saved": preview_saved,
})
except Exception as e:
logger.error("CivitAI fetch error: %s", e)
@@ -257,6 +283,28 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
all_meta[mname]["sha256"] = sha
_service._save_metadata(all_meta)
# Auto-set previews for models without preview image
dirs = _get_model_dirs(model_type)
preview_saved_count = 0
for model_name, result in results.items():
civitai = result.get("civitai")
if not civitai or not civitai.get("images"):
continue
if _service.find_preview_image(model_type, model_name):
continue
model_file = None
for d in dirs:
p = d / model_name
if p.is_file():
model_file = p
break
if not model_file:
continue
preview_path = model_file.parent / (model_file.stem + ".preview.png")
if CivitaiService.download_image(civitai["images"][0], preview_path):
preview_saved_count += 1
logger.info("Auto-saved preview for %s from CivitAI", model_name)
# Send final result
summary = {
"total": len(model_files),
@@ -264,6 +312,7 @@ async def handle_civitai_batch(request: web.Request) -> web.Response:
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
"errors": sum(1 for r in results.values() if r.get("error")),
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
"preview_saved": preview_saved_count,
}
await response.write(send_sse("done", summary))
await response.write_eof()
@@ -386,3 +435,134 @@ async def handle_get_filepath(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error resolving model filepath: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model Enable / Disable ────────────────────────────────
async def handle_get_disabled(request: web.Request) -> web.Response:
"""GET /api/wfm/models/disabled?type=checkpoint
Returns list of disabled model names (normalized, without .disabled suffix).
"""
model_type = request.query.get("type", "")
if not model_type:
return web.json_response({"error": "type required"}, status=400)
try:
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
return web.json_response(result)
except Exception as e:
logger.error("Error scanning disabled models: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_model(request: web.Request) -> web.Response:
"""POST /api/wfm/models/toggle
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
Renames the model file to add/remove .disabled suffix.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_name = body.get("model_name", "")
enabled = body.get("enabled", True)
if not model_type or not model_name:
return web.json_response({"error": "model_type and model_name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
return web.json_response({"status": "ok", "enabled": enabled})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except OSError as e:
logger.error("OS error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
except Exception as e:
logger.error("Error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_group(request: web.Request) -> web.Response:
"""POST /api/wfm/models/group-toggle
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
Enables or disables all models belonging to the specified group.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
group_name = body.get("group_name", "")
enabled = body.get("enabled", True)
if not model_type or not group_name:
return web.json_response({"error": "model_type and group_name required"}, status=400)
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
members = groups.get(group_name, [])
ok_list = []
error_list = []
for model_name in members:
if ".." in model_name:
continue
try:
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
ok_list.append(model_name)
except Exception as e:
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({
"status": "ok",
"enabled": enabled,
"ok": ok_list,
"errors": error_list,
})
except Exception as e:
logger.error("Error toggling group: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_models(request: web.Request) -> web.Response:
"""POST /api/wfm/models/delete
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
Deletes each model file and associated preview/sidecar files, then removes metadata.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_names = body.get("model_names", [])
if not model_type or not isinstance(model_names, list) or not model_names:
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
ok_list = []
error_list = []
for model_name in model_names:
if ".." in str(model_name):
error_list.append({"model": model_name, "error": "Invalid model name"})
continue
try:
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
ok_list.append({"model": model_name, "deleted": result["deleted"]})
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
except FileNotFoundError as e:
error_list.append({"model": model_name, "error": str(e)})
except Exception as e:
logger.error("Error deleting model %s: %s", model_name, e)
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
except Exception as e:
logger.error("Error in delete_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
+143 -1
View File
@@ -1,18 +1,30 @@
"""Settings API routes."""
import asyncio
import json
import logging
from pathlib import Path
from aiohttp import web
from ..services.settings_service import SettingsService
from ..config import DEFAULT_WORKFLOWS_DIR
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
logger = logging.getLogger(__name__)
_service = SettingsService()
# Data files included in export/import
_DATA_FILES = [
"settings.json",
"metadata.json",
"node_metadata.json",
"node_sets.json",
"prompts.json",
"model_metadata.json",
"gallery_metadata.json",
]
def setup_routes(app: web.Application):
"""Register settings API routes."""
@@ -20,6 +32,10 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings", handle_post)
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
app.router.add_get("/api/wfm/settings/export", handle_export)
app.router.add_post("/api/wfm/settings/import", handle_import)
async def handle_get(request: web.Request) -> web.Response:
@@ -92,3 +108,129 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error setting workflows dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _get_comfyui_output_dir() -> str:
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
try:
import folder_paths # type: ignore
return str(folder_paths.get_output_directory())
except Exception:
pass
# fallback: config.pyのCOMFYUI_ROOTから推測
from ..config import PLUGIN_DIR
comfyui_root = PLUGIN_DIR.parent.parent
output_dir = comfyui_root / "output"
if output_dir.is_dir():
return str(output_dir)
return ""
async def handle_get_output_dir(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/output-dir - Get gallery output directory."""
try:
server_settings = await asyncio.to_thread(_service.load)
saved = server_settings.get("gallery_output_dir", "").strip()
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
return web.json_response({
"current": saved or default_dir,
"default": default_dir,
"saved": saved,
})
except Exception as e:
logger.error("Error getting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_set_output_dir(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/output-dir - Save gallery output directory."""
try:
body = await request.json()
new_dir = body.get("gallery_output_dir", "").strip()
await asyncio.to_thread(_service.update, {"gallery_output_dir": new_dir})
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
resolved = new_dir or default_dir
# GalleryServiceの許可ルートを更新
from ..routes.gallery_routes import _service as gallery_service
gallery_service.update_output_root(resolved)
return web.json_response({
"status": "ok",
"current": resolved,
"default": default_dir,
"saved": new_dir,
})
except Exception as e:
logger.error("Error setting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _load_data_file(filename: str):
"""Load a single data file, return empty dict/list if missing."""
path = DATA_DIR / filename
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _build_export_bundle() -> dict:
"""Collect all data files into a single export bundle."""
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
for filename in _DATA_FILES:
data = _load_data_file(filename)
if data is not None:
bundle[filename] = data
return bundle
async def handle_export(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
try:
bundle = await asyncio.to_thread(_build_export_bundle)
body = json.dumps(bundle, ensure_ascii=False, indent=2)
return web.Response(
body=body.encode("utf-8"),
content_type="application/json",
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
)
except Exception as e:
logger.error("Error exporting data: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _apply_import_bundle(bundle: dict) -> dict:
"""Write imported bundle back to individual data files. Returns summary."""
summary = {"imported": [], "skipped": []}
DATA_DIR.mkdir(parents=True, exist_ok=True)
for filename in _DATA_FILES:
if filename not in bundle:
summary["skipped"].append(filename)
continue
try:
path = DATA_DIR / filename
with open(path, "w", encoding="utf-8") as f:
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
summary["imported"].append(filename)
except Exception as e:
logger.error("Import failed for %s: %s", filename, e)
summary["skipped"].append(filename)
return summary
async def handle_import(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
try:
bundle = await request.json()
if not isinstance(bundle, dict):
return web.json_response({"error": "Invalid bundle format"}, status=400)
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
return web.json_response({"status": "ok", **summary})
except Exception as e:
logger.error("Error importing data: %s", e)
return web.json_response({"error": str(e)}, status=500)
+98
View File
@@ -0,0 +1,98 @@
"""Wildcard file management API routes."""
import asyncio
import logging
from aiohttp import web
from ..config import DATA_DIR, COMFYUI_ROOT
from ..services.wildcard_service import WildcardService
logger = logging.getLogger(__name__)
_service = WildcardService(DATA_DIR / "wildcard")
def setup_routes(app: web.Application):
app.router.add_get("/api/wfm/wildcards", handle_list)
app.router.add_get("/api/wfm/wildcards/content", handle_get_content)
app.router.add_post("/api/wfm/wildcards/save", handle_save)
app.router.add_post("/api/wfm/wildcards/delete", handle_delete)
app.router.add_get("/api/wfm/wildcards/link-status", handle_link_status)
app.router.add_post("/api/wfm/wildcards/create-link", handle_create_link)
app.router.add_post("/api/wfm/wildcards/remove-link", handle_remove_link)
async def handle_list(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.list_wildcards)
return web.json_response(result)
except Exception as e:
logger.error("Error listing wildcards: %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 wildcard 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 wildcard: %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 wildcard: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_link_status(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.get_link_status, COMFYUI_ROOT)
return web.json_response(result)
except Exception as e:
logger.error("Error getting wildcard link status: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_create_link(request: web.Request) -> web.Response:
try:
result = await asyncio.to_thread(_service.create_link, COMFYUI_ROOT)
return web.json_response({"status": "ok", **result})
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error creating wildcard link: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_remove_link(request: web.Request) -> web.Response:
try:
await asyncio.to_thread(_service.remove_link)
return web.json_response({"status": "ok"})
except RuntimeError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
logger.error("Error removing wildcard link: %s", e)
return web.json_response({"error": str(e)}, status=500)
+14
View File
@@ -196,6 +196,20 @@ class CivitaiService:
return results
@staticmethod
def download_image(url, save_path, timeout=15):
"""Download an image from URL and save to save_path. Returns True on success."""
try:
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
with urlopen(req, timeout=timeout) as resp:
data = resp.read()
with open(save_path, "wb") as f:
f.write(data)
return True
except Exception as e:
logger.warning("Failed to download preview from %s: %s", url, e)
return False
def clear_cache(self, sha256_hash=None):
"""Clear cache for a specific hash or all."""
cache = self._load_cache()
+142
View File
@@ -0,0 +1,142 @@
"""
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class GalleryMetadataStore:
"""
gallery_metadata.json の構造:
{
"images": {
"<abs_path>": {
"favorite": bool,
"tags": [str, ...],
"memo": str,
"groups": [str, ...]
}
},
"groups": [
{"name": str}
]
}
"""
def __init__(self, file_path: Path):
self.file_path = file_path
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:
loaded = json.load(f)
self._data = {
"images": loaded.get("images", {}),
"groups": loaded.get("groups", []),
}
except Exception as e:
logger.warning("GalleryMetadataStore: load error: %s", e)
def _save_to_disk(self):
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logger.error("GalleryMetadataStore: save error: %s", e)
return False
def _normalize_path(self, image_path: str) -> str:
return str(Path(image_path)).replace("\\", "/")
def get(self, image_path: str) -> dict:
key = self._normalize_path(image_path)
return dict(self._data["images"].get(key, {}))
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()
# ── グループ管理 ──────────────────────────────────────────────
def list_groups(self) -> list[dict]:
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()
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()
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()
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
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", [])
}
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
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
+542
View File
@@ -0,0 +1,542 @@
"""
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
"""
import json
import logging
import mimetypes
import os
import re
import struct
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# サポートする画像拡張子
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
# メタデータ保存ファイル (gallery専用)
from .gallery_metadata import GalleryMetadataStore
# PNGチャンク1個の最大許容サイズ (32MB)
_PNG_CHUNK_MAX = 32 * 1024 * 1024
# JPEGを読む際の最大ファイルサイズ (64MB)
_JPEG_READ_MAX = 64 * 1024 * 1024
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
_CACHE_TTL = 30.0
class _FolderCache:
"""フォルダ単位の画像スキャン結果キャッシュ。
フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。"""
def __init__(self):
# folder_path -> (mtime, scan_time, [(name, path_str, size, mtime_f), ...])
self._cache: dict[str, tuple[float, float, list]] = {}
def get(self, folder: Path) -> list | None:
key = str(folder)
if key not in self._cache:
return None
cached_mtime, scan_time, entries = self._cache[key]
# TTL チェック
if time.monotonic() - scan_time > _CACHE_TTL:
return None
# フォルダ自体のmtimeチェック
try:
current_mtime = folder.stat().st_mtime
except OSError:
return None
if current_mtime != cached_mtime:
return None
return entries
def set(self, folder: Path, entries: list):
key = str(folder)
try:
folder_mtime = folder.stat().st_mtime
except OSError:
folder_mtime = 0.0
self._cache[key] = (folder_mtime, time.monotonic(), entries)
def invalidate(self, folder: Path):
self._cache.pop(str(folder), None)
class GalleryService:
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
self._allowed_root: Path | None = None
self._folder_cache = _FolderCache()
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 _check_path_allowed(self, path: Path) -> bool:
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
if self._allowed_root is None:
resolved = path.resolve()
return resolved == path.resolve()
try:
path.resolve().relative_to(self._allowed_root)
return True
except ValueError:
return False
# ──────────────────────────────────────────────────────────────
# フォルダツリー
# ──────────────────────────────────────────────────────────────
def list_folder_tree(self, root_path: str) -> dict:
"""outputフォルダのフォルダツリーを返す"""
root = Path(root_path).resolve()
if not root.is_dir():
return {"error": f"Directory not found: {root_path}"}
if self._allowed_root is None:
self._allowed_root = root
def build_tree(path: Path, rel_base: Path) -> dict:
rel = str(path.relative_to(rel_base)).replace("\\", "/")
image_count = 0
children = []
try:
with os.scandir(path) as it:
for entry in it:
if entry.is_file(follow_symlinks=True):
if Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
image_count += 1
elif entry.is_dir(follow_symlinks=True) and not entry.name.startswith("."):
children.append(entry.name)
except PermissionError:
pass
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
return {
"name": path.name,
"path": rel if rel != "." else "",
"abs_path": str(path).replace("\\", "/"),
"image_count": image_count,
"children": children_nodes,
}
return build_tree(root, root)
# ──────────────────────────────────────────────────────────────
# 画像一覧
# ──────────────────────────────────────────────────────────────
def _scan_folder(self, folder: Path) -> list[tuple[str, str, int, float]]:
"""os.scandir() で画像ファイルを1回のシステムコールで列挙。
キャッシュがあればそれを返す。
Returns: [(name, abs_path_str, size, mtime), ...]
"""
cached = self._folder_cache.get(folder)
if cached is not None:
return cached
entries = []
try:
with os.scandir(folder) as it:
for entry in it:
if not entry.is_file(follow_symlinks=True):
continue
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
continue
try:
st = entry.stat(follow_symlinks=True)
entries.append((
entry.name,
str(Path(entry.path).resolve()).replace("\\", "/"),
st.st_size,
st.st_mtime,
))
except OSError:
continue
except PermissionError:
pass
self._folder_cache.set(folder, entries)
return entries
def list_images(
self,
folder_path: str,
search: str = "",
sort_by: str = "date_desc",
favorite_only: bool = False,
tag_filter: str = "",
group_filter: str = "",
) -> list[dict]:
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
folder = Path(folder_path).resolve()
if not folder.is_dir():
return []
if not self._check_path_allowed(folder):
logger.warning("list_images: path not allowed: %s", folder)
return []
# グループフィルタ: サーバーサイドで先にパスSetを取得(転送量削減)
group_member_set: set | None = None
if group_filter:
group_member_set = self.metadata_store.get_group_member_set(group_filter)
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
raw_entries = self._scan_folder(folder)
results = []
for name, abs_path, size, mtime in raw_entries:
# グループフィルタ(サーバーサイド)
if group_member_set is not None and abs_path not in group_member_set:
continue
meta = self.metadata_store.get(abs_path)
# お気に入りフィルタ(サーバーサイド、早期スキップ)
if favorite_only and not meta.get("favorite", False):
continue
# タグフィルタ(サーバーサイド、早期スキップ)
tags = meta.get("tags", [])
if tag_filter and tag_filter not in tags:
continue
ext = Path(name).suffix.lower()
item = {
"filename": name,
"path": abs_path,
"size": size,
"mtime": mtime,
"ext": ext,
"favorite": meta.get("favorite", False),
"tags": tags,
"memo": meta.get("memo", ""),
"groups": meta.get("groups", []),
}
# 検索フィルタ
if search:
s = search.lower()
if not (
s in name.lower()
or s in item["memo"].lower()
or any(s in t.lower() for t in tags)
):
continue
results.append(item)
# ソート
if sort_by == "name_asc":
results.sort(key=lambda x: x["filename"].lower())
elif sort_by == "name_desc":
results.sort(key=lambda x: x["filename"].lower(), reverse=True)
elif sort_by == "date_asc":
results.sort(key=lambda x: x["mtime"])
else: # date_desc (default)
results.sort(key=lambda x: x["mtime"], reverse=True)
return results
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
def get_image_metadata(self, image_path: str) -> dict:
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
path = Path(image_path).resolve()
if not path.is_file():
return {"error": "File not found"}
if not self._check_path_allowed(path):
return {"error": "Access denied"}
embedded = {}
ext = path.suffix.lower()
if ext == ".png":
embedded = self._read_png_metadata(path)
elif ext in {".jpg", ".jpeg"}:
embedded = self._read_jpeg_metadata(path)
saved = self.metadata_store.get(str(path))
stat = path.stat()
return {
"filename": path.name,
"path": str(path).replace("\\", "/"),
"size": stat.st_size,
"mtime": stat.st_mtime,
"ext": ext,
"embedded": embedded,
"favorite": saved.get("favorite", False),
"tags": saved.get("tags", []),
"memo": saved.get("memo", ""),
"groups": saved.get("groups", []),
}
def _read_png_metadata(self, path: Path) -> dict:
"""PNGのtEXtチャンクからメタデータを抽出"""
result = {}
try:
with open(path, "rb") as f:
sig = f.read(8)
if sig != b"\x89PNG\r\n\x1a\n":
return result
while True:
chunk_len_bytes = f.read(4)
if len(chunk_len_bytes) < 4:
break
chunk_len = struct.unpack(">I", chunk_len_bytes)[0]
chunk_type = f.read(4).decode("ascii", errors="ignore")
if chunk_len > _PNG_CHUNK_MAX:
f.seek(chunk_len + 4, 1)
if chunk_type == "IEND":
break
continue
chunk_data = f.read(chunk_len)
f.read(4) # CRC
if chunk_type in ("tEXt", "iTXt"):
try:
if chunk_type == "tEXt":
null_idx = chunk_data.index(b"\x00")
key = chunk_data[:null_idx].decode("latin-1")
value = chunk_data[null_idx + 1:].decode("latin-1")
else:
null_idx = chunk_data.index(b"\x00")
key = chunk_data[:null_idx].decode("utf-8")
rest = chunk_data[null_idx + 1:]
rest = rest[2:]
null_idx2 = rest.index(b"\x00")
rest = rest[null_idx2 + 1:]
null_idx3 = rest.index(b"\x00")
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
result[key] = value
except (ValueError, UnicodeDecodeError):
pass
if chunk_type == "IEND":
break
except Exception as e:
logger.debug("PNG metadata read error: %s", e)
return result
def _read_jpeg_metadata(self, path: Path) -> dict:
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
result = {}
try:
file_size = path.stat().st_size
read_size = min(file_size, _JPEG_READ_MAX)
with open(path, "rb") as f:
data = f.read(read_size)
idx = 0
while idx < len(data) - 3:
if data[idx] == 0xFF:
marker = data[idx + 1]
if marker == 0xFE: # COM
length = struct.unpack(">H", data[idx + 2:idx + 4])[0]
end = idx + 2 + length
comment = data[idx + 4:end].decode("utf-8", errors="replace")
result["Comment"] = comment
break
idx += 2
else:
idx += 1
except Exception as e:
logger.debug("JPEG metadata read error: %s", e)
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
"""
path = Path(image_path).resolve()
if not self._check_path_allowed(path):
return None
if path.suffix.lower() == ".png":
embedded = self._read_png_metadata(path)
# 1. workflow キー (ComfyUI UI形式)
for key in ("workflow", "Workflow"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
for key in ("prompt", "Prompt"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 3. gallery_metadata.json に保存されたworkflow
saved = self.metadata_store.get(str(path))
return saved.get("workflow") or None
# ──────────────────────────────────────────────────────────────
# メタデータ保存
# ──────────────────────────────────────────────────────────────
def save_image_meta(self, image_path: str, data: dict) -> bool:
return self.metadata_store.save(image_path, data)
def toggle_favorite(self, image_path: str) -> bool:
meta = self.metadata_store.get(image_path)
new_val = not meta.get("favorite", False)
self.metadata_store.save(image_path, {"favorite": new_val})
return new_val
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
def list_groups(self) -> list[dict]:
return self.metadata_store.list_groups()
def create_group(self, name: str) -> bool:
return self.metadata_store.create_group(name)
def rename_group(self, old_name: str, new_name: str) -> bool:
return self.metadata_store.rename_group(old_name, new_name)
def delete_group(self, name: str) -> bool:
return self.metadata_store.delete_group(name)
def add_to_group(self, image_path: str, group_name: str) -> bool:
meta = self.metadata_store.get(image_path)
groups = meta.get("groups", [])
if group_name not in groups:
groups.append(group_name)
return self.metadata_store.save(image_path, {"groups": groups})
return True
def remove_from_group(self, image_path: str, group_name: str) -> bool:
meta = self.metadata_store.get(image_path)
groups = [g for g in meta.get("groups", []) if g != group_name]
return self.metadata_store.save(image_path, {"groups": groups})
def list_images_in_group(self, group_name: str) -> list[str]:
return self.metadata_store.list_images_in_group(group_name)
# ──────────────────────────────────────────────────────────────
# 画像配信
# ──────────────────────────────────────────────────────────────
def create_folder(self, parent_path: str, name: str) -> dict:
"""選択フォルダ内に新しいサブフォルダを作成する"""
parent = Path(parent_path).resolve()
if not parent.is_dir():
return {"ok": False, "error": "Parent directory not found"}
if not self._check_path_allowed(parent):
return {"ok": False, "error": "Access denied"}
name = name.strip()
invalid_chars = set(r'\/:*?"<>|')
if not name or any(c in invalid_chars for c in name):
return {"ok": False, "error": "Invalid folder name"}
new_folder = parent / name
if new_folder.exists():
return {"ok": False, "error": "Folder already exists"}
try:
new_folder.mkdir()
self._folder_cache.invalidate(parent)
return {"ok": True, "path": str(new_folder).replace("\\", "/")}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_folder(self, folder_path: str) -> dict:
"""フォルダを再帰的に削除する"""
import shutil
folder = Path(folder_path).resolve()
if not folder.is_dir():
return {"ok": False, "error": "Directory not found"}
if not self._check_path_allowed(folder):
return {"ok": False, "error": "Access denied"}
if self._allowed_root and folder == self._allowed_root:
return {"ok": False, "error": "Cannot delete root folder"}
try:
parent = folder.parent
shutil.rmtree(folder)
self._folder_cache.invalidate(parent)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_images(self, paths: list) -> dict:
"""画像ファイルを削除する(複数対応)"""
deleted = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
try:
p.unlink()
self.metadata_store.delete(str(p))
self._folder_cache.invalidate(p.parent)
deleted.append(str(p).replace("\\", "/"))
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"deleted": deleted, "errors": errors}
def move_images(self, paths: list, dest_folder: str) -> dict:
"""画像ファイルを別フォルダへ移動する(複数対応)"""
dest = Path(dest_folder).resolve()
if not dest.is_dir():
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
if not self._check_path_allowed(dest):
return {"ok": False, "error": "Access denied", "moved": [], "errors": []}
moved = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
dest_path = dest / p.name
counter = 1
while dest_path.exists():
dest_path = dest / f"{p.stem}_{counter}{p.suffix}"
counter += 1
try:
p.rename(dest_path)
old_str = str(p).replace("\\", "/")
new_str = str(dest_path).replace("\\", "/")
self.metadata_store.rename_path(old_str, new_str)
self._folder_cache.invalidate(p.parent)
self._folder_cache.invalidate(dest)
moved.append({"from": old_str, "to": new_str, "filename": dest_path.name})
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"moved": moved, "errors": errors}
def serve_image(self, image_path: str):
"""画像のPathオブジェクトを返す(ルートで使用)"""
p = Path(image_path).resolve()
if not p.is_file():
return None
if p.suffix.lower() not in IMAGE_EXTENSIONS:
return None
if not self._check_path_allowed(p):
logger.warning("serve_image: path not allowed: %s", p)
return None
return p
+134 -4
View File
@@ -13,6 +13,15 @@ logger = logging.getLogger(__name__)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# All sidecar extensions to delete when a model is deleted
_SIDECAR_EXTENSIONS = [
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp",
".json", ".civitai.info", ".info",
]
_DISABLED_SUFFIX = ".disabled"
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
@@ -21,6 +30,8 @@ MODEL_TYPE_FOLDER_KEYS = {
"controlnet": "controlnet",
"unet": "diffusion_models",
"textencoder": "text_encoders",
"hypernetwork": "hypernetworks",
"embedding": "embeddings",
}
@@ -91,21 +102,95 @@ class ModelsService:
entry["memo"] = updates["memo"]
if "sha256" in updates:
entry["sha256"] = updates["sha256"]
if "badges" in updates:
entry["badges"] = updates["badges"]
entry["updatedAt"] = self._now_iso()
data[model_name] = entry
self._save_metadata(data)
return entry
def get_model_groups(self):
def get_model_groups(self, model_type=None):
data = self._load_metadata()
return data.get("_groups", {})
raw = data.get("_groups", {})
def save_model_groups(self, groups):
# Migrate old flat format { "groupName": [...] } → per-type { "checkpoint": {...} }
if raw and not any(k in MODEL_TYPE_FOLDER_KEYS for k in raw):
logger.info("Migrating groups to per-type format (old data moved to _groups_legacy)")
data["_groups_legacy"] = raw
data["_groups"] = {}
self._save_metadata(data)
raw = {}
if model_type:
return raw.get(model_type, {})
return raw
def save_model_groups(self, groups, model_type):
data = self._load_metadata()
data["_groups"] = groups
if not isinstance(data.get("_groups"), dict):
data["_groups"] = {}
data["_groups"][model_type] = groups
self._save_metadata(data)
return groups
def find_model_file(self, model_type, model_name):
"""Find a model file, checking both enabled and disabled states.
Returns (Path, is_enabled) or (None, None) if not found.
"""
dirs = _get_model_dirs(model_type)
for d in dirs:
enabled = d / model_name
if enabled.is_file():
return enabled, True
disabled = d / (model_name + _DISABLED_SUFFIX)
if disabled.is_file():
return disabled, False
return None, None
def enable_model(self, model_type, model_name):
"""Rename model.safetensors.disabled → model.safetensors."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if is_enabled:
return
target = path.parent / path.name[: -len(_DISABLED_SUFFIX)]
path.rename(target)
logger.info("Enabled model: %s", model_name)
def disable_model(self, model_type, model_name):
"""Rename model.safetensors → model.safetensors.disabled."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if not is_enabled:
return
target = path.parent / (path.name + _DISABLED_SUFFIX)
path.rename(target)
logger.info("Disabled model: %s", model_name)
def scan_disabled_models(self, model_type):
"""Scan directories for .disabled files.
Returns list of normalized model names (without .disabled suffix).
"""
dirs = _get_model_dirs(model_type)
disabled = []
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
try:
rel = str(f.relative_to(d))
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
disabled.append(rel.replace("\\", "/"))
except ValueError:
pass
return disabled
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
@@ -144,3 +229,48 @@ class ModelsService:
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
def delete_model(self, model_type, model_name):
"""Delete a model file and all associated preview/sidecar files.
Also removes the model's metadata entry.
Returns dict with deleted file paths.
"""
if ".." in model_name:
raise ValueError(f"Invalid model name: {model_name}")
path, _ = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
deleted = []
parent = path.parent
# Compute stem from the original (non-disabled) filename.
# path.name for a disabled file is e.g. "model.safetensors.disabled"
# so we strip the .disabled suffix first, then take Path.stem.
orig_name = path.name
if orig_name.endswith(_DISABLED_SUFFIX):
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
stem = Path(orig_name).stem # "model.safetensors" -> "model"
# Delete the model file itself
path.unlink()
deleted.append(str(path))
logger.info("Deleted model file: %s", path)
# Delete all sidecar files (previews, metadata, info)
for ext in _SIDECAR_EXTENSIONS:
sidecar = parent / (stem + ext)
if sidecar.is_file():
sidecar.unlink()
deleted.append(str(sidecar))
logger.info("Deleted sidecar: %s", sidecar)
# Remove metadata entry
data = self._load_metadata()
if model_name in data:
del data[model_name]
self._save_metadata(data)
return {"deleted": deleted}
+205
View File
@@ -0,0 +1,205 @@
"""Wildcard file management service."""
import logging
import os
import platform
import shutil
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
ALLOWED_EXTS = {"txt", "yaml", "yml"}
class WildcardService:
def __init__(self, wildcard_dir: Path):
self.wildcard_dir = wildcard_dir
# Only mkdir if it is a real directory (not a junction/symlink)
if not self.wildcard_dir.exists() and not self._is_junction(self.wildcard_dir):
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# File CRUD
# ------------------------------------------------------------------
def list_wildcards(self) -> list[dict]:
"""Return sorted list of wildcard files recursively (follows junction/symlink)."""
if not self.wildcard_dir.exists():
return []
files = []
for path in sorted(self.wildcard_dir.rglob("*")):
if path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS:
rel = path.relative_to(self.wildcard_dir)
dir_posix = rel.parent.as_posix()
if dir_posix == ".":
dir_posix = ""
files.append({
"name": path.stem,
"filename": rel.as_posix(),
"ext": path.suffix.lstrip(".").lower(),
"size": path.stat().st_size,
"dir": dir_posix,
"wc_name": rel.with_suffix("").as_posix(),
})
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.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
rel = path.relative_to(self.wildcard_dir)
dir_posix = rel.parent.as_posix()
if dir_posix == ".":
dir_posix = ""
return {
"name": path.stem,
"filename": rel.as_posix(),
"ext": path.suffix.lstrip(".").lower(),
"size": path.stat().st_size,
"dir": dir_posix,
"wc_name": rel.with_suffix("").as_posix(),
}
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
# Normalize to forward slashes and strip leading/trailing slashes
filename = filename.replace("\\", "/").strip("/")
# Validate each path component — block traversal and empty parts
parts = filename.split("/")
for p in parts:
if not p or p == ".." or p == ".":
return None
ext = Path(parts[-1]).suffix.lstrip(".").lower()
if ext not in ALLOWED_EXTS:
return None
return self.wildcard_dir.joinpath(*parts)
# ------------------------------------------------------------------
# Impact Pack integration
# ------------------------------------------------------------------
@staticmethod
def find_impact_pack_wildcards(comfyui_root: Path) -> Path | None:
"""Return Impact Pack wildcards directory path, or None if not installed."""
custom_nodes = comfyui_root / "custom_nodes"
if not custom_nodes.is_dir():
return None
for d in custom_nodes.iterdir():
if d.is_dir() and "impact-pack" in d.name.lower():
wildcards = d / "wildcards"
if wildcards.is_dir():
return wildcards
return None
def get_link_status(self, comfyui_root: Path) -> dict:
"""Return current Impact Pack integration status."""
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
is_linked = self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()
resolved_target = None
if is_linked:
try:
resolved_target = str(self.wildcard_dir.resolve())
except Exception:
pass
return {
"impact_pack_installed": impact_dir is not None,
"impact_pack_wildcards_dir": str(impact_dir) if impact_dir else None,
"wfs_wildcard_dir": str(self.wildcard_dir),
"is_linked": is_linked,
"link_target": resolved_target,
}
def create_link(self, comfyui_root: Path) -> dict:
"""Replace WFS wildcard dir with a junction/symlink → Impact Pack wildcards."""
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
if impact_dir is None:
raise RuntimeError("ComfyUI-Impact-Pack is not installed.")
if self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink():
raise RuntimeError("Link already exists.")
# Migrate existing WFS files to Impact Pack dir
migrated: list[str] = []
if self.wildcard_dir.is_dir():
for f in self.wildcard_dir.iterdir():
if f.is_file():
dest = impact_dir / f.name
if not dest.exists():
shutil.copy2(str(f), str(dest))
migrated.append(f.name)
shutil.rmtree(str(self.wildcard_dir))
# Create junction (Windows) or symlink (others)
self._create_junction_or_symlink(self.wildcard_dir, impact_dir)
logger.info("Wildcard link created: %s -> %s", self.wildcard_dir, impact_dir)
return {"migrated_files": migrated}
def remove_link(self) -> None:
"""Remove junction/symlink and restore WFS wildcard dir as a plain directory."""
if not (self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()):
raise RuntimeError("No link to remove.")
self._remove_junction_or_symlink(self.wildcard_dir)
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
logger.info("Wildcard link removed, restored plain dir: %s", self.wildcard_dir)
# ------------------------------------------------------------------
# Platform helpers
# ------------------------------------------------------------------
@staticmethod
def _is_junction(path: Path) -> bool:
"""Return True if path is a Windows directory junction."""
if platform.system() != "Windows":
return False
try:
import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
return attrs != 0xFFFFFFFF and bool(attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception:
return False
@staticmethod
def _create_junction_or_symlink(link: Path, target: Path) -> None:
if platform.system() == "Windows":
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"mklink /J failed: {result.stderr.strip()}")
else:
os.symlink(target, link, target_is_directory=True)
@staticmethod
def _remove_junction_or_symlink(path: Path) -> None:
if platform.system() == "Windows":
result = subprocess.run(
["cmd", "/c", "rmdir", str(path)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"rmdir failed: {result.stderr.strip()}")
else:
os.unlink(str(path))
+101 -20
View File
@@ -3,6 +3,23 @@
import os
import re
# CLIPLoader / DualCLIPLoader の type フィールド → モデル種別マッピング
_CLIP_TYPE_TO_MODEL = {
"flux": "Flux",
"flux2": "Flux2",
"newbie": "NewBie",
"ovis": "Ovis",
"qwen_image": "Qwen",
"sd3": "SD3",
"sd3.5": "SD3",
"hidream_i": "HiDream",
"hidream_e": "HiDream",
"hidream": "HiDream",
"wan": "Wan",
"cosmos": "Cosmos",
"lumina": "Lumina",
}
def _model_name_from_ui_node(node):
"""Extract model filename from UI format node (lowercase)."""
@@ -11,16 +28,65 @@ def _model_name_from_ui_node(node):
return val.lower() if isinstance(val, str) else ""
def _clip_type_from_ui_node(node):
"""Extract CLIPLoader type string from widgets_values (lowercase)."""
ntype = node.get("type", "")
vals = node.get("widgets_values", [])
if ntype == "CLIPLoader" and len(vals) > 1:
return str(vals[1]).lower() if isinstance(vals[1], str) else ""
if ntype in ("DualCLIPLoader", "TripleCLIPLoader") and len(vals) > 2:
return str(vals[2]).lower() if isinstance(vals[2], str) else ""
return ""
def _model_name_from_api_node(node):
"""Extract model filename from API format node (lowercase)."""
inputs = node.get("inputs", {})
for key in ("ckpt_name", "unet_name", "model_name"):
for key in ("ckpt_name", "unet_name", "model_name", "clip_name1", "clip_name"):
val = inputs.get(key, "")
if isinstance(val, str) and val:
return val.lower()
return ""
def _collect_all_ui_nodes(workflow_data):
"""Yield all UI-format nodes including those inside definitions.subgraphs."""
for node in workflow_data.get("nodes", []):
yield node
defs = workflow_data.get("definitions", {})
if isinstance(defs, dict):
for sg in defs.get("subgraphs", []):
if isinstance(sg, dict):
for node in sg.get("nodes", []):
yield node
def _detect_model_type_from_name(mn_base, model_types):
"""Detect model type from a model filename (already lowercased basename)."""
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
model_types.add("SD1.5")
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
model_types.add("SDXL")
elif re.search(r"flux[-_.]?2", mn_base):
model_types.add("Flux2")
elif "flux" in mn_base:
model_types.add("Flux")
elif re.search(r"sd[._-]?3", mn_base):
model_types.add("SD3")
elif "qwen" in mn_base:
model_types.add("Qwen")
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
model_types.add("Z-IMAGE")
elif "newbie" in mn_base:
model_types.add("NewBie")
elif "ovis" in mn_base:
model_types.add("Ovis")
elif "hidream" in mn_base:
model_types.add("HiDream")
elif "wan" in mn_base and re.search(r"wan[-_.]?\d|wan[-_]video", mn_base):
model_types.add("Wan")
def analyze_workflow(workflow_data, filename=""):
"""Analyze workflow JSON and return model types, input/output node counts."""
model_types = set()
@@ -36,23 +102,33 @@ def analyze_workflow(workflow_data, filename=""):
model_types.add("Qwen")
if "zimage" in fn or "z-image" in fn or "zit" in fn:
model_types.add("Z-IMAGE")
if "flux2" in fn:
if re.search(r"flux[-_.]?2", fn):
model_types.add("Flux2")
elif "flux" in fn:
model_types.add("Flux")
if "sd3" in fn:
model_types.add("SD3")
if "newbie" in fn:
model_types.add("NewBie")
if "ovis" in fn:
model_types.add("Ovis")
if "hidream" in fn:
model_types.add("HiDream")
if "wan" in fn and re.search(r"wan[-_.]?\d|wan[-_]video", fn):
model_types.add("Wan")
# Support both UI format (nodes array) and API format (node ID object)
raw_nodes = workflow_data.get("nodes", None)
if raw_nodes is not None:
# UI / app format: collect all nodes including subgraph definitions
node_iter = [
(
node.get("type", ""),
node.get("title", "").lower(),
_model_name_from_ui_node(node),
_clip_type_from_ui_node(node),
)
for node in raw_nodes
for node in _collect_all_ui_nodes(workflow_data)
]
else:
node_iter = []
@@ -62,33 +138,32 @@ def analyze_workflow(workflow_data, filename=""):
ntype = node.get("class_type", "")
title = node.get("_meta", {}).get("title", "").lower()
mn = _model_name_from_api_node(node)
node_iter.append((ntype, title, mn))
node_iter.append((ntype, title, mn, ""))
for ntype, title, mn in node_iter:
for ntype, title, mn, clip_type in node_iter:
if (
"CheckpointLoader" in ntype
or "UNETLoader" in ntype
or "UnetLoader" in ntype # UnetLoaderGGUF 等を含む
or "unet_name" in ntype
):
mn_base = os.path.basename(mn.replace("\\", "/"))
if re.search(r"sd1[._-]?5|v1[._-]5", mn_base):
model_types.add("SD1.5")
elif re.search(r"sdxl|[._-]xl[._-]|[._-]xl$|xl[._-]", mn_base):
model_types.add("SDXL")
elif "flux2" in mn_base:
model_types.add("Flux2")
elif "flux" in mn_base:
model_types.add("Flux")
elif re.search(r"sd[._-]?3", mn_base):
model_types.add("SD3")
elif "qwen" in mn_base:
model_types.add("Qwen")
elif "zimage" in mn_base or "z-image" in mn_base or "zit" in mn_base:
model_types.add("Z-IMAGE")
_detect_model_type_from_name(mn_base, model_types)
# CLIPLoader 系: type フィールドまたはファイル名からモデル種別を検出
# QuadrupleCLIPLoader 等、type フィールドを持たない新型ローダーにも対応
if "CLIPLoader" in ntype:
if clip_type:
detected = _CLIP_TYPE_TO_MODEL.get(clip_type)
if detected:
model_types.add(detected)
mn_base = os.path.basename(mn.replace("\\", "/"))
if mn_base:
_detect_model_type_from_name(mn_base, model_types)
if "sdxl" in title:
model_types.add("SDXL")
if "flux2" in title:
if re.search(r"flux[-_.]?2", title):
model_types.add("Flux2")
elif "flux" in title:
model_types.add("Flux")
@@ -98,6 +173,12 @@ def analyze_workflow(workflow_data, filename=""):
model_types.add("Qwen")
if "zimage" in title or "z-image" in title or "zit" in title:
model_types.add("Z-IMAGE")
if "newbie" in title:
model_types.add("NewBie")
if "ovis" in title:
model_types.add("Ovis")
if "hidream" in title:
model_types.add("HiDream")
if ntype == "CLIPTextEncode" or "prompt" in title:
inputs["prompts"] += 1
+2 -1
View File
@@ -112,6 +112,7 @@ class WorkflowService:
"summary": meta.get("summary", ""),
"modelTypesOverride": override_types,
"favorite": bool(meta.get("favorite", False)),
"badges": meta.get("badges", []),
},
"mtime": stat.st_mtime,
"thumbnail": thumbnail,
@@ -132,7 +133,7 @@ class WorkflowService:
"""Save metadata (tags, memo, summary, etc.) for a workflow."""
metadata = self._load_metadata()
entry = metadata.get(filename, {})
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite"):
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges"):
if key in updates:
entry[key] = updates[key]
entry["updatedAt"] = self._now_iso()
+3 -1
View File
@@ -61,7 +61,7 @@ class WorkflowStudio:
app.router.add_get("/wfm", serve_index_page)
# API routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
@@ -70,5 +70,7 @@ class WorkflowStudio:
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
gallery_routes.setup_routes(app)
wildcard_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "comfyui-workflow-studio"
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
version = "0.3.7"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
[project.urls]
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
[tool.comfy]
PublisherId = "statsu"
DisplayName = "Workflow Studio"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/docs/thumb.png"
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
+649
View File
@@ -0,0 +1,649 @@
/* ============================================================
Gallery Tab Styles
============================================================ */
/* ── 3カラムレイアウト ──────────────────────────────────────── */
.wfm-gallery-layout {
display: flex;
height: calc(100vh - var(--wfm-header-height, 96px));
overflow: hidden;
gap: 0;
}
/* ── 左: フォルダツリーパネル ──────────────────────────────── */
.wfm-gallery-tree-panel {
width: 250px;
min-width: 250px;
max-width: 250px;
border-right: 1px solid var(--wfm-border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wfm-bg-secondary, #1a1a2e);
}
.wfm-gallery-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 4px;
}
.wfm-gallery-tree-title {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1;
}
.wfm-gallery-tree-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.wfm-gallery-tree {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
.wfm-gallery-tree-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
color: var(--wfm-text-primary);
-webkit-user-select: none;
user-select: none;
transition: background 0.1s;
}
.wfm-gallery-tree-item:hover {
background: var(--wfm-bg-hover, rgba(255,255,255,0.06));
}
.wfm-gallery-tree-item.selected {
background: var(--wfm-accent, #4f8ef7);
color: #fff;
}
.wfm-gallery-tree-item.selected .wfm-gallery-tree-count {
background: rgba(255,255,255,0.25);
}
.wfm-gallery-tree-arrow {
font-size: 10px;
width: 14px;
flex-shrink: 0;
cursor: pointer;
color: var(--wfm-text-secondary);
transition: transform 0.15s;
}
.wfm-gallery-tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.wfm-gallery-tree-count {
font-size: 10px;
background: var(--wfm-accent-muted, rgba(79,142,247,0.2));
color: var(--wfm-accent, #4f8ef7);
border-radius: 8px;
padding: 0 5px;
line-height: 1.4;
flex-shrink: 0;
}
.wfm-gallery-tree-children {
/* 子ノードコンテナ */
}
/* ── 中: 画像一覧パネル ────────────────────────────────────── */
.wfm-gallery-list-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
border-right: 1px solid var(--wfm-border);
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-grid {
padding: 8px;
}
/* サムネイルビュー */
.wfm-gallery-view-thumb {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
align-items: start;
}
.wfm-gallery-thumb-card {
position: relative;
background: var(--wfm-bg-secondary, #1a1a2e);
border: 1px solid var(--wfm-border);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.wfm-gallery-thumb-card:hover {
border-color: var(--wfm-accent, #4f8ef7);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.wfm-gallery-thumb-card.selected {
border-color: var(--wfm-accent, #4f8ef7);
box-shadow: 0 0 0 2px var(--wfm-accent, #4f8ef7);
}
.wfm-gallery-thumb-card.multi-selected {
border-color: #f59e0b;
box-shadow: 0 0 0 2px #f59e0b;
}
.wfm-gallery-thumb-img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: var(--wfm-bg-hover, #111);
}
.wfm-gallery-thumb-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--wfm-bg-hover, #222);
color: var(--wfm-text-secondary);
font-size: 24px;
}
/* お気に入りボタン(サムネイルカード右上) */
.wfm-gallery-thumb-fav-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 13px;
line-height: 1;
padding: 0;
cursor: pointer;
color: rgba(255,255,255,0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-fav-btn {
opacity: 1;
}
.wfm-gallery-thumb-fav-btn.active {
color: #fbbf24;
opacity: 1;
}
.wfm-gallery-thumb-fav-btn:hover {
background: rgba(0,0,0,0.75);
color: #fbbf24;
}
/* テーブルビューのお気に入りボタン */
.wfm-gallery-table-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: rgba(255,255,255,0.3);
padding: 2px 4px;
transition: color 0.15s;
}
.wfm-gallery-table-fav-btn:hover,
.wfm-gallery-table-fav-btn.active {
color: #fbbf24;
}
.wfm-gallery-thumb-tags {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px 6px;
}
/* テーブルビュー */
.wfm-gallery-view-table {
display: block;
}
.wfm-gallery-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.wfm-gallery-table thead th {
position: sticky;
top: 0;
background: var(--wfm-bg-secondary, #1a1a2e);
color: var(--wfm-text-secondary);
font-weight: 600;
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid var(--wfm-border);
z-index: 1;
}
.wfm-gallery-table tbody tr {
cursor: pointer;
transition: background 0.1s;
}
.wfm-gallery-table tbody tr:hover {
background: var(--wfm-bg-hover, rgba(255,255,255,0.05));
}
.wfm-gallery-table tbody tr.selected {
background: rgba(79,142,247,0.15);
}
.wfm-gallery-table tbody tr.multi-selected {
background: rgba(245,158,11,0.15);
}
.wfm-gallery-table td {
padding: 4px 8px;
border-bottom: 1px solid var(--wfm-border);
color: var(--wfm-text-primary);
vertical-align: middle;
}
.wfm-gallery-table-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 3px;
display: block;
}
.wfm-gallery-table-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── タグバッジ共通 ────────────────────────────────────────── */
.wfm-gallery-tag-badge {
display: inline-flex;
align-items: center;
gap: 3px;
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: 1px 7px;
font-size: 11px;
white-space: nowrap;
}
.wfm-gallery-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 12px;
line-height: 1;
opacity: 0.7;
margin-left: 1px;
}
.wfm-gallery-tag-remove:hover {
opacity: 1;
}
/* ── 右: 詳細パネル ────────────────────────────────────────── */
.wfm-gallery-detail-panel {
width: 280px;
min-width: 280px;
max-width: 280px;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wfm-bg-secondary, #1a1a2e);
}
.wfm-gallery-detail-header {
padding: 6px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-detail-filename {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* プレビュー */
.wfm-gallery-detail-preview {
width: 100%;
aspect-ratio: 1 / 1;
max-height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--wfm-bg-primary, #16213e);
border-bottom: 1px solid var(--wfm-border);
overflow: hidden;
flex-shrink: 0;
}
.wfm-gallery-detail-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
cursor: pointer;
}
/* タブコンテンツ */
.wfm-gallery-detail-tab-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.wfm-gallery-section-title {
font-size: 13px;
font-weight: 600;
color: var(--wfm-text-primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.wfm-gallery-info-section {
margin-bottom: 4px;
padding: 6px 0;
}
.wfm-gallery-info-row {
font-size: 12px;
color: var(--wfm-text-primary);
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-gallery-tags-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.wfm-gallery-memo {
width: 100%;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
color: var(--wfm-text-primary);
font-size: 12px;
padding: 6px 8px;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
.wfm-gallery-memo:focus {
outline: none;
border-color: var(--wfm-accent, #4f8ef7);
}
/* ── Metadataタブ(Workflow全幅表示) ─────────────────────── */
.wfm-gallery-meta-tab {
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
height: 100%;
}
.wfm-gallery-meta-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 8px;
}
.wfm-gallery-meta-status {
font-size: 11px;
color: var(--wfm-text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-gallery-workflow-json {
flex: 1;
margin: 0;
padding: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 11px;
line-height: 1.5;
color: var(--wfm-text-primary);
background: var(--wfm-bg-secondary, #1a1a2e);
overflow: auto;
white-space: pre;
word-break: normal;
border: none;
}
/* グループ */
.wfm-gallery-current-groups {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
font-size: 12px;
color: var(--wfm-text-secondary);
}
.wfm-gallery-groups-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.wfm-gallery-group-row {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
font-size: 12px;
}
.wfm-gallery-group-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wfm-text-primary);
}
/* ── 一括操作バー ──────────────────────────────────────────── */
.wfm-gallery-bulk-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: rgba(245,158,11,0.12);
border-bottom: 1px solid rgba(245,158,11,0.35);
flex-shrink: 0;
flex-wrap: wrap;
}
.wfm-gallery-bulk-count {
font-size: 12px;
font-weight: 600;
color: #f59e0b;
white-space: nowrap;
margin-right: 4px;
}
/* ── ライトボックス ─────────────────────────────────────────── */
.wfm-gallery-lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.wfm-gallery-lightbox-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
max-width: 90vw;
max-height: 90vh;
}
.wfm-gallery-lightbox-img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
cursor: default;
}
.wfm-gallery-lightbox-footer {
margin-top: 8px;
color: rgba(255,255,255,0.7);
font-size: 13px;
text-align: center;
}
.wfm-gallery-lightbox-close {
position: absolute;
top: -12px;
right: -12px;
background: rgba(0,0,0,0.7);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.wfm-gallery-lightbox-close:hover {
background: rgba(255,255,255,0.2);
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
display: flex;
gap: 6px;
padding-top: 4px;
}
.wfm-gallery-file-actions .wfm-btn {
flex: 1;
}
/* ── 移動先選択モーダル ──────────────────────────────────────── */
.wfm-gallery-move-modal {
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 8px;
padding: 20px;
min-width: 320px;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 12px;
}
.wfm-gallery-move-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--wfm-text-primary);
}
.wfm-gallery-move-dest-sel {
width: 100%;
padding: 6px 8px;
font-size: 13px;
}
.wfm-gallery-move-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
+1713 -39
View File
File diff suppressed because it is too large Load Diff
+455
View File
@@ -0,0 +1,455 @@
/**
* AI Tab - Translation, VLM, Settings
* Supports Ollama and LM Studio as backends
*/
import { showToast } from "./app.js";
import { t } from "./i18n.js";
const SETTINGS_KEY = "wfm_ai_settings";
const LANG_NAMES = {
ja: "Japanese",
en: "English",
zh: "Chinese",
};
// ============================================
// Settings persistence
// ============================================
function loadAiSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
} catch {
return {};
}
}
function saveAiSettings(patch) {
const data = { ...loadAiSettings(), ...patch };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
return data;
}
const VLM_PROMPTS = {
describe: "Describe this image in detail.",
prompt: "Create a detailed Stable Diffusion image generation prompt based on this image. Output only the prompt text, nothing else.",
tags: "Generate a list of descriptive tags for this image. Output only comma-separated tags in English, nothing else.",
};
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const b64 = e.target.result.split(",")[1];
resolve({ base64: b64, mimeType: file.type });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
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 }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).response || "";
} else {
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "user", content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
]}],
stream: false,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).choices?.[0]?.message?.content || "";
}
}
// ============================================
// URL validation
// ============================================
function isValidBackendUrl(url) {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
// ============================================
// API helpers
// ============================================
async function fetchModels(url, backend) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/tags`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return (data.models || []).map((m) => m.name);
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/models`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return (data.data || []).map((m) => m.id);
}
}
async function testConnection(url, backend) {
const models = await fetchModels(url, backend);
return models.length;
}
async function callLLM(url, backend, model, prompt) {
if (backend === "ollama") {
const res = await fetch(`${url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, stream: false }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.response || "";
} else {
// LM Studio (OpenAI-compatible)
const res = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
stream: false,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.choices?.[0]?.message?.content || "";
}
}
// ============================================
// 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}`;
}
// ============================================
// Translation tab
// ============================================
function initTranslateTab() {
const srcLangSel = document.getElementById("wfm-ai-src-lang");
const dstLangSel = document.getElementById("wfm-ai-dst-lang");
const swapBtn = document.getElementById("wfm-ai-trans-swap-btn");
const inputEl = document.getElementById("wfm-ai-trans-input");
const outputEl = document.getElementById("wfm-ai-trans-output");
const transBtn = document.getElementById("wfm-ai-trans-btn");
const copyBtn = document.getElementById("wfm-ai-trans-copy-btn");
const statusEl = document.getElementById("wfm-ai-trans-status");
if (!transBtn) return;
// Restore saved language selections
const saved = loadAiSettings();
if (saved.srcLang && srcLangSel) srcLangSel.value = saved.srcLang;
if (saved.dstLang && dstLangSel) dstLangSel.value = saved.dstLang;
// Persist language selections
srcLangSel?.addEventListener("change", () => saveAiSettings({ srcLang: srcLangSel.value }));
dstLangSel?.addEventListener("change", () => saveAiSettings({ dstLang: dstLangSel.value }));
// Swap languages
swapBtn?.addEventListener("click", () => {
const tmp = srcLangSel.value;
srcLangSel.value = dstLangSel.value;
dstLangSel.value = tmp;
saveAiSettings({ srcLang: srcLangSel.value, dstLang: dstLangSel.value });
// Swap text content too
const tmpText = inputEl.value;
inputEl.value = outputEl.value;
outputEl.value = tmpText;
});
// Translate
transBtn.addEventListener("click", async () => {
const text = inputEl.value.trim();
if (!text) {
showToast(t("aiToastNoText"), "error");
return;
}
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
if (!model) {
showToast(t("aiToastNoModel"), "error");
return;
}
transBtn.disabled = true;
statusEl.textContent = t("aiStatusTranslating");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
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";
} catch (err) {
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
showToast(t("aiToastTransFailed") + err.message, "error");
} finally {
transBtn.disabled = false;
}
});
// Copy translated text
copyBtn?.addEventListener("click", () => {
const text = outputEl.value;
if (!text) {
showToast(t("aiToastNoCopyText"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("aiToastCopied"), "success");
});
});
}
// ============================================
// Settings tab
// ============================================
async function refreshModels() {
const settings = loadAiSettings();
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
const url = document.getElementById("wfm-ai-backend-url")?.value?.trim() || "";
const modelSel = document.getElementById("wfm-ai-model-select");
const refreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
if (!modelSel) return;
if (refreshBtn) refreshBtn.disabled = true;
try {
const models = await fetchModels(url, backend);
modelSel.innerHTML = `<option value="">-- ${t("aiSettingsModelPlaceholder")} --</option>`;
models.forEach((name) => {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
if (name === settings.model) opt.selected = true;
modelSel.appendChild(opt);
});
if (models.length === 0) showToast(t("aiToastNoModels"), "error");
} catch (err) {
showToast(t("aiToastModelsFailed") + err.message, "error");
} finally {
if (refreshBtn) refreshBtn.disabled = false;
}
}
function initSettingsTab() {
const saved = loadAiSettings();
// Restore saved values
const backendRadios = document.querySelectorAll("input[name='wfm-ai-backend']");
backendRadios.forEach((r) => {
if (r.value === (saved.backend || "ollama")) r.checked = true;
});
const urlInput = document.getElementById("wfm-ai-backend-url");
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
// Update URL placeholder when backend changes
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;
});
});
// Connection test
document.getElementById("wfm-ai-test-btn")?.addEventListener("click", async () => {
const testBtn = document.getElementById("wfm-ai-test-btn");
const resultEl = document.getElementById("wfm-ai-test-result");
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
const url = urlInput?.value?.trim() || "";
if (!isValidBackendUrl(url)) {
if (resultEl) {
resultEl.textContent = t("aiToastInvalidUrlInput");
resultEl.className = "wfm-ai-test-result wfm-ai-status-error";
}
return;
}
if (testBtn) testBtn.disabled = true;
if (resultEl) {
resultEl.textContent = t("aiStatusConnecting");
resultEl.className = "wfm-ai-test-result wfm-ai-status-working";
}
try {
const count = await testConnection(url, backend);
if (resultEl) {
resultEl.textContent = `${t("aiStatusConnectOk")} (${count} ${t("aiModels")})`;
resultEl.className = "wfm-ai-test-result wfm-ai-status-ok";
}
await refreshModels();
} catch (err) {
if (resultEl) {
resultEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
resultEl.className = "wfm-ai-test-result wfm-ai-status-error";
}
} finally {
if (testBtn) testBtn.disabled = false;
}
});
// Model refresh
document.getElementById("wfm-ai-model-refresh-btn")?.addEventListener("click", () => refreshModels());
// Free language inputs
const freeSrcInput = document.getElementById("wfm-ai-free-src-lang");
const freeDstInput = document.getElementById("wfm-ai-free-dst-lang");
if (freeSrcInput && saved.freeSrcLang) freeSrcInput.value = saved.freeSrcLang;
if (freeDstInput && saved.freeDstLang) freeDstInput.value = saved.freeDstLang;
// Save settings
document.getElementById("wfm-ai-settings-save-btn")?.addEventListener("click", () => {
const backend = document.querySelector("input[name='wfm-ai-backend']:checked")?.value || "ollama";
const url = urlInput?.value?.trim() || "";
const model = document.getElementById("wfm-ai-model-select")?.value || "";
const freeSrcLang = freeSrcInput?.value?.trim() || "";
const freeDstLang = freeDstInput?.value?.trim() || "";
if (url && !isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
saveAiSettings({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
showToast(t("aiToastSettingsSaved"), "success");
});
// Load models on init if settings exist
if (saved.backendUrl && saved.backend) {
refreshModels().catch(() => {});
}
}
// ============================================
// 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 fileInput = document.getElementById("wfm-ai-vlm-file");
const taskSel = document.getElementById("wfm-ai-vlm-task");
const runBtn = document.getElementById("wfm-ai-vlm-run");
const statusEl = document.getElementById("wfm-ai-vlm-status");
const resultEl = document.getElementById("wfm-ai-vlm-result");
const copyBtn = document.getElementById("wfm-ai-vlm-copy");
if (!dropEl) return;
let vlmImage = null; // { base64, mimeType }
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";
};
dropEl.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", e => { if (e.target.files[0]) loadImage(e.target.files[0]); });
dropEl.addEventListener("dragover", e => { e.preventDefault(); dropEl.classList.add("drag-over"); });
dropEl.addEventListener("dragleave", () => dropEl.classList.remove("drag-over"));
dropEl.addEventListener("drop", e => {
e.preventDefault();
dropEl.classList.remove("drag-over");
if (e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0]);
});
runBtn.addEventListener("click", async () => {
if (!vlmImage) { showToast(t("aiToastNoImage"), "error"); return; }
const settings = loadAiSettings();
const { backend = "ollama", backendUrl, model } = settings;
const url = backendUrl || (backend === "ollama" ? "http://localhost:11434" : "http://localhost:1234");
if (!isValidBackendUrl(url)) {
showToast(t("aiToastInvalidUrl"), "error");
return;
}
if (!model) { showToast(t("aiToastNoModel"), "error"); return; }
const task = taskSel?.value || "describe";
runBtn.disabled = true;
statusEl.textContent = t("aiStatusRunning");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-working";
resultEl.value = "";
try {
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
resultEl.value = result.trim();
statusEl.textContent = t("aiStatusDone");
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
} catch (err) {
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
showToast(t("aiToastVlmFailed") + err.message, "error");
} finally {
runBtn.disabled = false;
}
});
copyBtn.addEventListener("click", () => {
const text = resultEl.value;
if (!text) { showToast(t("aiToastNoCopyText"), "error"); return; }
navigator.clipboard.writeText(text).then(() => showToast(t("aiToastCopied"), "success"));
});
}
// ============================================
// Export
// ============================================
export function initAiTab() {
initTranslateTab();
initVlmTab();
initSettingsTab();
}
+119 -5
View File
@@ -90,7 +90,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", metadata: "tabMetadata", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp", ai: "tabAi" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -188,6 +188,14 @@ function applyI18nToHtml() {
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
});
// Gallery tab
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
gallerySideTabBtns.forEach(btn => {
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
});
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
@@ -216,6 +224,67 @@ function applyI18nToHtml() {
const presetCopyNegBtn = document.getElementById("wfm-preset-copy-neg-btn");
if (presetCopyNegBtn) presetCopyNegBtn.textContent = t("copyNegativePrompt");
// AI tab
document.querySelectorAll(".wfm-ai-subtab-btn").forEach(btn => {
if (btn.dataset.aiSubtab === "translate") btn.textContent = t("aiSubTabTranslate");
if (btn.dataset.aiSubtab === "vlm") btn.textContent = t("aiSubTabVlm");
if (btn.dataset.aiSubtab === "ai-settings") btn.textContent = t("aiSubTabSettings");
});
const aiLangMap = { ja: "aiLangJa", en: "aiLangEn", zh: "aiLangZh", free: "aiLangFree" };
[document.getElementById("wfm-ai-src-lang"), document.getElementById("wfm-ai-dst-lang")].forEach(sel => {
if (!sel) return;
sel.querySelectorAll("option").forEach(opt => {
const key = aiLangMap[opt.value];
if (key) opt.textContent = t(key);
});
});
const aiTransInput = document.getElementById("wfm-ai-trans-input");
if (aiTransInput) aiTransInput.placeholder = t("aiTransInputPlaceholder");
const aiTransOutput = document.getElementById("wfm-ai-trans-output");
if (aiTransOutput) aiTransOutput.placeholder = t("aiTransOutputPlaceholder");
const aiTransBtn = document.getElementById("wfm-ai-trans-btn");
if (aiTransBtn) aiTransBtn.textContent = t("aiTranslateBtn");
const aiTransCopyBtn = document.getElementById("wfm-ai-trans-copy-btn");
if (aiTransCopyBtn) aiTransCopyBtn.textContent = t("aiCopyBtn");
const aiVlmLabel = document.getElementById("wfm-ai-vlm-label");
if (aiVlmLabel) aiVlmLabel.textContent = t("aiVlmDropLabel");
const aiVlmTask = document.getElementById("wfm-ai-vlm-task");
if (aiVlmTask) {
aiVlmTask.querySelectorAll("option").forEach(opt => {
if (opt.value === "describe") opt.textContent = t("aiVlmDescribe");
if (opt.value === "prompt") opt.textContent = t("aiVlmPromptCreate");
});
}
const aiVlmRunBtn = document.getElementById("wfm-ai-vlm-run");
if (aiVlmRunBtn) aiVlmRunBtn.textContent = t("aiVlmRunBtn");
const aiVlmResult = document.getElementById("wfm-ai-vlm-result");
if (aiVlmResult) aiVlmResult.placeholder = t("aiVlmResultPlaceholder");
const aiVlmCopyBtn = document.getElementById("wfm-ai-vlm-copy");
if (aiVlmCopyBtn) aiVlmCopyBtn.textContent = t("aiCopyBtn");
const aiBackendTitle = document.getElementById("wfm-ai-settings-backend-title");
if (aiBackendTitle) aiBackendTitle.textContent = t("aiSettingsBackend");
const aiConnTitle = document.getElementById("wfm-ai-settings-conn-title");
if (aiConnTitle) aiConnTitle.textContent = t("aiSettingsConnection");
const aiTestBtn = document.getElementById("wfm-ai-test-btn");
if (aiTestBtn) aiTestBtn.textContent = t("aiSettingsTestBtn");
const aiModelTitle = document.getElementById("wfm-ai-settings-model-title");
if (aiModelTitle) aiModelTitle.textContent = t("aiSettingsModelSection");
const aiModelSel = document.getElementById("wfm-ai-model-select");
if (aiModelSel) {
const firstOpt = aiModelSel.querySelector("option[value='']");
if (firstOpt) firstOpt.textContent = t("aiSettingsModelPlaceholder");
}
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
if (aiFreeSrcLabel) aiFreeSrcLabel.textContent = t("aiSettingsInputLang");
const aiFreeDstLabel = document.getElementById("wfm-ai-free-dst-label");
if (aiFreeDstLabel) aiFreeDstLabel.textContent = t("aiSettingsOutputLang");
const aiSaveBtn = document.getElementById("wfm-ai-settings-save-btn");
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
// Group panel labels
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
if (propTitle) propTitle.textContent = t("properties");
@@ -252,31 +321,58 @@ function applyI18nToHtml() {
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
"wfm-help-wf-7": "helpWf7", "wfm-help-wf-8": "helpWf8",
"wfm-help-wf-7": "helpWf7",
"wfm-help-gen-1": "helpGen1", "wfm-help-gen-2": "helpGen2",
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
"wfm-help-gen-11": "helpGen11",
"wfm-help-feeder-title": "helpFeederTitle",
"wfm-help-feeder-desc": "helpFeederDesc",
"wfm-help-feeder-1": "helpFeeder1", "wfm-help-feeder-2": "helpFeeder2",
"wfm-help-feeder-3": "helpFeeder3", "wfm-help-feeder-4": "helpFeeder4",
"wfm-help-feeder-5": "helpFeeder5", "wfm-help-feeder-6": "helpFeeder6",
"wfm-help-feeder-7": "helpFeeder7", "wfm-help-feeder-8": "helpFeeder8",
"wfm-help-feeder-9": "helpFeeder9",
"wfm-help-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-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
"wfm-help-prompt-9": "helpPrompt9",
"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-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
"wfm-help-models-7": "helpModels7",
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
"wfm-help-models-11": "helpModels11",
"wfm-help-gallery-title": "helpGalleryTitle",
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
"wfm-help-gallery-11": "helpGallery11",
"wfm-help-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
"wfm-help-sidepanel-13": "helpSidepanel13",
"wfm-help-sidepanel-14": "helpSidepanel14", "wfm-help-sidepanel-15": "helpSidepanel15",
"wfm-help-sidepanel-16": "helpSidepanel16",
"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-shortcuts-title": "helpShortcutsTitle",
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
@@ -330,12 +426,27 @@ 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 } from "./settings-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
import { initModelsTab } from "./models-tab.js";
import { initGalleryTab } from "./gallery-tab.js";
import { initMetadataTab } from "./metadata-tab.js";
import { initAiTab } from "./ai-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
// Apply saved textarea font size
try {
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
} catch {}
// Apply saved JSON highlight colors
try {
const _s = JSON.parse(localStorage.getItem("wfm_settings") || "{}");
applyJsonColors(_s.jsonColors);
} catch {}
document.addEventListener("DOMContentLoaded", () => {
applyI18nToHtml();
initTabs();
@@ -346,6 +457,9 @@ document.addEventListener("DOMContentLoaded", () => {
initModelsTab();
initGenerateTab();
initPromptTab();
initMetadataTab();
initGalleryTab();
initAiTab();
console.log("Workflow Studio: App initialized");
});
+26 -1
View File
@@ -88,7 +88,13 @@ export const comfyUI = {
try {
const info = await this.fetchObjectInfo(cls);
if (info?.[cls]?.input?.required?.[inputKey]) {
return info[cls].input.required[inputKey][0] || [];
const inputDef = info[cls].input.required[inputKey];
const first = inputDef[0];
if (Array.isArray(first)) return first;
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
return inputDef[1].values;
}
}
} catch {}
}
@@ -134,6 +140,25 @@ export const comfyUI = {
);
},
async fetchHypernetworks() {
return this._fetchModelList(
["HypernetworkLoader"],
"hypernetwork_name"
);
},
async fetchEmbeddings() {
// ComfyUI exposes embeddings via /embeddings endpoint
try {
const res = await fetch(`${this.baseUrl}/embeddings`);
if (res.ok) {
const list = await res.json();
return Array.isArray(list) ? list : [];
}
} catch {}
return [];
},
async fetchSamplers() {
return this._fetchModelList(["KSampler"], "sampler_name");
},
+26 -15
View File
@@ -61,6 +61,7 @@ export const comfyEditor = {
this.renderImageTab(analysis, "wfm-gen-image-fields");
this.renderModelTab(analysis, "wfm-gen-model-fields");
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
_syncRawJson();
},
renderPromptTab(analysis, containerId) {
@@ -76,7 +77,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">
${positiveNodes.map((n) => `<option value="${n.id}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${positiveNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-pos-apply">Apply</button>
@@ -87,7 +88,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">
${negativeNodes.map((n) => `<option value="${n.id}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${negativeNodes.map((n) => `<option value="${n.id}" data-text-key="${n.textKey || "text"}" selected>ID:${n.id} (${n.title})</option>`).join("")}
${nodeOpts}
</select>
<button class="wfm-btn wfm-btn-sm" id="wfm-prompt-neg-apply">Apply</button>
@@ -100,7 +101,9 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-prompt-pos-target")?.value;
const text = document.getElementById("wfm-prompt-pos-text")?.value;
if (nodeId && comfyUI.currentWorkflow?.[nodeId]) {
comfyUI.currentWorkflow[nodeId].inputs.text = text;
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
const textKey = promptNode?.textKey || "text";
comfyUI.currentWorkflow[nodeId].inputs[textKey] = text;
_syncRawJson();
}
});
@@ -109,7 +112,9 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-prompt-neg-target")?.value;
const text = document.getElementById("wfm-prompt-neg-text")?.value;
if (nodeId && comfyUI.currentWorkflow?.[nodeId]) {
comfyUI.currentWorkflow[nodeId].inputs.text = text;
const promptNode = (analysis.prompt_nodes || []).find(n => n.id === nodeId);
const textKey = promptNode?.textKey || "text";
comfyUI.currentWorkflow[nodeId].inputs[textKey] = text;
_syncRawJson();
}
});
@@ -208,11 +213,11 @@ export const comfyEditor = {
const latent = analysis.latent_nodes?.[0];
el.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div>
<h3 style="font-size:14px;margin-bottom:12px;">KSampler</h3>
<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>
${sampler ? `
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}">
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}" data-seed-key="${sampler.seedKey || "seed"}">
<div class="wfm-form-group">
<label>Seed</label>
<input type="number" class="wfm-input" id="wfm-settings-seed" value="${sampler.seed ?? -1}">
@@ -244,8 +249,8 @@ export const comfyEditor = {
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply">Apply</button>
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
</div>
<div>
<h3 style="font-size:14px;margin-bottom:12px;">Latent Image</h3>
<div style="flex:1;min-width:0;padding-left:14px;">
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">Latent Image</h3>
${latent ? `
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
<div class="wfm-form-group">
@@ -270,7 +275,9 @@ export const comfyEditor = {
const nodeId = document.getElementById("wfm-settings-sampler-id")?.value;
if (!nodeId || !comfyUI.currentWorkflow?.[nodeId]) return;
const inputs = comfyUI.currentWorkflow[nodeId].inputs;
inputs.seed = parseInt(document.getElementById("wfm-settings-seed")?.value) || -1;
// 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;
@@ -401,16 +408,20 @@ export const comfyEditor = {
syncToWorkflow() {
// Sync prompt texts before generation
const posTarget = document.getElementById("wfm-prompt-pos-target")?.value;
const posSelect = document.getElementById("wfm-prompt-pos-target");
const posTarget = posSelect?.value;
const posText = document.getElementById("wfm-prompt-pos-text")?.value;
if (posTarget && comfyUI.currentWorkflow?.[posTarget]) {
comfyUI.currentWorkflow[posTarget].inputs.text = posText;
const posTextKey = posSelect.selectedOptions[0]?.dataset?.textKey || "text";
comfyUI.currentWorkflow[posTarget].inputs[posTextKey] = posText;
}
const negTarget = document.getElementById("wfm-prompt-neg-target")?.value;
const negSelect = document.getElementById("wfm-prompt-neg-target");
const negTarget = negSelect?.value;
const negText = document.getElementById("wfm-prompt-neg-text")?.value;
if (negTarget && comfyUI.currentWorkflow?.[negTarget]) {
comfyUI.currentWorkflow[negTarget].inputs.text = negText;
const negTextKey = negSelect.selectedOptions[0]?.dataset?.textKey || "text";
comfyUI.currentWorkflow[negTarget].inputs[negTextKey] = negText;
}
},
};
+183 -10
View File
@@ -343,6 +343,24 @@ export const comfyWorkflow = {
wIdx++;
}
}
// 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
// (e.g. "Select Wildcard 🟢 Full Cache" vs "Select Wildcard"), causing
// "Prompt outputs failed validation". Fall back to first choice when mismatch.
if (expectedType === "COMBO") {
const allInputDefs = {
...(objectInfo[node.type]?.input?.required || {}),
...(objectInfo[node.type]?.input?.optional || {}),
};
const spec = allInputDefs[name];
if (spec) {
const choices = Array.isArray(spec[0]) ? spec[0] : null;
if (choices && choices.length > 0 && !choices.includes(val)) {
val = choices[0];
}
}
}
inputs[name] = val;
}
} else {
@@ -446,7 +464,7 @@ export const comfyWorkflow = {
if (!workflow) return result;
// Build sampler connections for prompt classification
// === Pass 1: Collect sampler refs + all_nodes ===
const samplerPositiveRef = {};
const samplerNegativeRef = {};
@@ -462,40 +480,174 @@ export const comfyWorkflow = {
if (Array.isArray(inputs.positive)) samplerPositiveRef[inputs.positive[0]] = true;
if (Array.isArray(inputs.negative)) samplerNegativeRef[inputs.negative[0]] = true;
// KSamplerAdvanced uses noise_seed instead of seed
const seedKey = "seed" in inputs ? "seed" : "noise_seed";
result.sampler_nodes.push({
id, type: ct, title,
seed: inputs.seed, steps: inputs.steps, cfg: inputs.cfg,
seed: inputs.seed ?? inputs.noise_seed,
seedKey,
steps: inputs.steps, cfg: inputs.cfg,
sampler_name: inputs.sampler_name, scheduler: inputs.scheduler,
denoise: inputs.denoise,
});
}
}
// === Pass 1b: Expand refs through CONDITIONING graph (BFS, 5 iterations) ===
// Nodes that pass CONDITIONING through without changing positive/negative role
const COND_PASSTHROUGH = new Set([
"ConditioningCombine", "ConditioningConcat", "ConditioningAverage",
"ConditioningZeroOut", "ConditioningSetTimestepRange",
"ControlNetApply", "ControlNetApplyAdvanced",
"IPAdapterApply", "IPAdapterApplyFaceID",
"StyleModelApply",
]);
for (let iter = 0; iter < 5; iter++) {
for (const [id, node] of Object.entries(workflow)) {
if (!node?.class_type) continue;
const ct = node.class_type;
const inputs = node.inputs || {};
const isPos = !!samplerPositiveRef[id];
const isNeg = !!samplerNegativeRef[id];
if (!isPos && !isNeg) continue;
if (COND_PASSTHROUGH.has(ct)) {
for (const v of Object.values(inputs)) {
if (Array.isArray(v)) {
if (isPos) samplerPositiveRef[v[0]] = true;
if (isNeg) samplerNegativeRef[v[0]] = true;
}
}
}
// Propagate role through text encoder linked inputs (CLIPTextEncode → upstream text source)
if (ct === "CLIPTextEncode") {
const tv = inputs.text;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
for (const key of ["text_g", "text_l"]) {
const tv = inputs[key];
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
const tv = inputs.wildcard_text;
if (Array.isArray(tv)) {
if (isPos) samplerPositiveRef[tv[0]] = true;
if (isNeg) samplerNegativeRef[tv[0]] = true;
}
}
}
}
// === Pass 2: Main analysis ===
for (const [id, node] of Object.entries(workflow)) {
if (!node?.class_type) continue;
const ct = node.class_type;
const inputs = node.inputs || {};
const title = node._meta?.title || ct;
if (ct.includes("CheckpointLoader")) {
const isPos = !!samplerPositiveRef[id];
const isNeg = !!samplerNegativeRef[id];
const getRole = () => {
if (isPos && !isNeg) return "positive";
if (isNeg && !isPos) return "negative";
if (/pos|正/i.test(title)) return "positive";
if (/neg|負/i.test(title)) return "negative";
return "unknown";
};
// --- Checkpoint nodes ---
// Matches: CheckpointLoaderSimple, CheckpointLoader, "Checkpoint Loader" (WAS), etc.
if (ct.includes("CheckpointLoader") || ct === "Checkpoint Loader") {
result.checkpoint_nodes.push({
id, type: ct, title,
ckpt_name: inputs.ckpt_name,
});
}
// --- Prompt nodes ---
if (ct === "CLIPTextEncode") {
let role = "unknown";
if (samplerPositiveRef[id]) role = "positive";
else if (samplerNegativeRef[id]) role = "negative";
else if (/pos|正/i.test(title)) role = "positive";
else if (/neg|負/i.test(title)) role = "negative";
// Only add when text is a direct string (if linked, upstream node will be detected)
const textVal = inputs.text;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "text",
});
}
}
// SDXL dual-text encoder
if (ct === "CLIPTextEncodeSDXL" || ct === "CLIPTextEncodeSDXLRefiner") {
const textG = inputs.text_g;
const textL = inputs.text_l;
// Add only if at least one text field is a direct string
if (typeof textG === "string" || typeof textL === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: typeof textG === "string" ? textG : (typeof textL === "string" ? textL : ""),
textKey: typeof textG === "string" ? "text_g" : "text_l",
});
}
}
// Qwen-based image+text encoder
if (ct === "TextEncodeQwenImageEditPlus") {
result.prompt_nodes.push({
id, type: ct, title, role,
text: inputs.text,
id, type: ct, title, role: getRole(),
text: inputs.prompt || "",
textKey: "prompt",
});
}
// SDXLPromptStyler / SDXLPromptStylerAdvanced — contains both pos & neg in one node
if (ct === "SDXLPromptStyler" || ct === "SDXLPromptStylerAdvanced") {
if (inputs.text_positive !== undefined) {
result.prompt_nodes.push({
id, type: ct, title: `${title} [positive]`, role: "positive",
text: inputs.text_positive || "", textKey: "text_positive",
});
}
if (inputs.text_negative !== undefined) {
result.prompt_nodes.push({
id, type: ct, title: `${title} [negative]`, role: "negative",
text: inputs.text_negative || "", textKey: "text_negative",
});
}
}
// ImpactWildcardEncode / ImpactWildcardProcessor — treat wildcard_text as prompt
if (ct === "ImpactWildcardEncode" || ct === "ImpactWildcardProcessor") {
const textVal = inputs.wildcard_text;
if (typeof textVal === "string") {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: textVal, textKey: "wildcard_text",
});
}
}
// PrimitiveStringMultiline / PrimitiveString — when feeding a prompt node
if ((ct === "PrimitiveStringMultiline" || ct === "PrimitiveString") && (isPos || isNeg)) {
result.prompt_nodes.push({
id, type: ct, title, role: getRole(),
text: inputs.value || "",
textKey: "value",
});
}
// --- Latent nodes ---
if (ct === "EmptyLatentImage" || ct === "EmptySD3LatentImage") {
result.latent_nodes.push({
id, type: ct, title,
@@ -504,6 +656,7 @@ export const comfyWorkflow = {
});
}
// --- LoRA nodes ---
if (ct === "LoraLoader" || ct === "LoraLoaderModelOnly") {
result.lora_nodes.push({
id, type: ct, title,
@@ -513,14 +666,31 @@ export const comfyWorkflow = {
});
}
// Power Lora Loader (rgthree) — dynamic lora_N inputs in API format
if (ct === "Power Lora Loader (rgthree)") {
for (const [k, v] of Object.entries(inputs)) {
if (/^lora_\d+$/.test(k) && typeof v === "string") {
result.lora_nodes.push({
id, type: ct, title,
lora_name: v,
strength_model: inputs[k.replace("lora_", "strength_")] ?? 1,
strength_clip: inputs[k.replace("lora_", "strength_clip_")] ?? 1,
});
}
}
}
// --- VAE nodes ---
if (ct === "VAELoader") {
result.vae_nodes.push({ id, type: ct, title, vae_name: inputs.vae_name });
}
// --- Save/preview nodes ---
if (ct === "SaveImage" || ct === "PreviewImage") {
result.save_nodes.push({ id, type: ct, title });
}
// --- Load image nodes ---
if (ct === "LoadImage" || ct === "LoadImageMask"
|| (ct.toLowerCase().includes("load") && ct.toLowerCase().includes("image") && inputs.image !== undefined)) {
result.load_image_nodes.push({
@@ -528,6 +698,7 @@ export const comfyWorkflow = {
});
}
// --- Text encoder (CLIP) nodes ---
if (ct === "DualCLIPLoader" || ct === "CLIPLoader") {
result.text_encoder_nodes.push({
id, type: ct, title,
@@ -536,12 +707,14 @@ export const comfyWorkflow = {
});
}
// --- Diffusion model nodes ---
if (ct === "UNETLoader" || ct === "UnetLoaderGGUF") {
result.diffusion_model_nodes.push({
id, type: ct, title, unet_name: inputs.unet_name,
});
}
// --- ControlNet loader nodes ---
if (ct.includes("ControlNetLoader")) {
result.controlnet_nodes.push({
id, type: ct, title,
+510
View File
@@ -0,0 +1,510 @@
/**
* Feeder Tab - ImageFeeder node control with embedded image library
*/
import { comfyUI } from "./comfyui-client.js";
import { showToast } from "./app.js";
// ── Module State ──────────────────────────────────────────────────
const _s = {
dir: "", // current folder relative path
images: [], // filenames in current folder
selected: new Set(),// selected filenames (across all interactions)
presets: {}, // {presetName: {directory, selected_files}}
running: false, // feeder run loop active
};
// ── Node helpers ──────────────────────────────────────────────────
function _feederNodes() {
const wf = comfyUI.currentWorkflow;
if (!wf) return [];
return Object.entries(wf)
.filter(([, n]) => n?.class_type === "ImageFeeder")
.map(([id, n]) => ({ id, title: n._meta?.title || `ImageFeeder` }));
}
export function refreshFeederNodeList() {
const sel = document.getElementById("wfm-feeder-node-sel");
if (!sel) return;
const nodes = _feederNodes();
if (nodes.length === 0) {
sel.innerHTML = `<option value="">-- No ImageFeeder node --</option>`;
return;
}
const prevVal = sel.value;
sel.innerHTML = nodes.map(n =>
`<option value="${n.id}">${n.title} (ID:${n.id})</option>`
).join("");
if (prevVal && nodes.find(n => n.id === prevVal)) sel.value = prevVal;
_loadFromNode(sel.value || nodes[0].id);
}
function _loadFromNode(nodeId) {
const wf = comfyUI.currentWorkflow;
if (!wf?.[nodeId]) return;
const inp = wf[nodeId].inputs || {};
_setVal("wfm-feeder-dir-input", inp.directory || "");
_setVal("wfm-feeder-sort", inp.sort_mode || "ascending");
_setVal("wfm-feeder-index", inp.index ?? 0);
_setVal("wfm-feeder-start", inp.start_index ?? 0);
_setVal("wfm-feeder-end", inp.end_index ?? 0);
_setVal("wfm-feeder-batch", inp.batch_size ?? 1);
_setVal("wfm-feeder-seed", inp.seed ?? 0);
_setCheck("wfm-feeder-use-sel", inp.use_selection ?? true);
// Restore selected_files
_s.selected.clear();
try {
const arr = JSON.parse(inp.selected_files || "[]");
if (Array.isArray(arr)) arr.forEach(f => _s.selected.add(f));
} catch {}
const dir = inp.directory || "";
if (_s.dir !== dir) {
_s.dir = dir;
_loadImages(dir);
_highlightTreeRow(dir);
} else {
_refreshGridCbs();
}
_updateStatus();
}
function _applyToWorkflow() {
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
const wf = comfyUI.currentWorkflow;
if (!nodeId || !wf?.[nodeId]) {
showToast("No ImageFeeder node selected", "error");
return;
}
_applyToNode(nodeId);
showToast("Applied to workflow", "success");
}
// ── Run Loop ──────────────────────────────────────────────────────
function _setRunUI(running) {
const runBtn = document.getElementById("wfm-feeder-run-btn");
const stopBtn = document.getElementById("wfm-feeder-stop-btn");
if (runBtn) runBtn.disabled = running;
if (stopBtn) stopBtn.disabled = !running;
}
function _applyToNode(nodeId) {
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
inp.directory = _s.dir;
inp.sort_mode = document.getElementById("wfm-feeder-sort")?.value || "ascending";
inp.index = parseInt(document.getElementById("wfm-feeder-index")?.value) || 0;
inp.start_index = parseInt(document.getElementById("wfm-feeder-start")?.value) || 0;
inp.end_index = parseInt(document.getElementById("wfm-feeder-end")?.value) || 0;
inp.batch_size = parseInt(document.getElementById("wfm-feeder-batch")?.value) || 1;
inp.seed = parseInt(document.getElementById("wfm-feeder-seed")?.value) || 0;
inp.use_selection = document.getElementById("wfm-feeder-use-sel")?.checked ?? true;
inp.selected_files = JSON.stringify([..._s.selected]);
}
async function _startRun() {
if (_s.running) return;
if (!comfyUI.currentWorkflow) { showToast("No workflow loaded", "error"); return; }
if (!comfyUI.connected) { showToast("Not connected to ComfyUI", "error"); return; }
const nodeId = document.getElementById("wfm-feeder-node-sel")?.value;
if (!nodeId || !comfyUI.currentWorkflow[nodeId]) {
showToast("No ImageFeeder node selected", "error");
return;
}
// WebSocket を先に接続してノード sync メッセージを受け取れるようにする
const wsOk = await comfyUI.connectWebSocket();
if (!wsOk) { showToast("WebSocket connection failed", "error"); return; }
_s.running = true;
_setRunUI(true);
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
// image_loop_node_sync メッセージを捕捉するリスナー
let _lastSync = null;
const _syncHandler = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "image_loop_node_sync" && String(msg.data?.node_id) === String(nodeId)) {
_lastSync = msg.data;
}
} catch {}
};
comfyUI.socket.addEventListener("message", _syncHandler);
let count = 0;
try {
while (_s.running) {
_lastSync = null;
_applyToNode(nodeId);
const wf = comfyUI.currentWorkflow;
const inp = wf[nodeId].inputs;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = `#${++count} 0%`;
// 右ペインのseed設定を使用(KSamplerに適用される)
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const { images, seed } = await comfyUI.generate(
{ ...wf },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `#${count} ${(pct * 100).toFixed(0)}%`;
},
}
);
// 右ペインのseed表示を更新(通常の生成と同じ挙動)
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0 && resultImg) {
const blob = await comfyUI.getImageBlob(images[0]);
resultImg.src = URL.createObjectURL(blob);
resultImg.style.display = "block";
}
if (!_s.running) break;
// control after generate: ノードから返された next_index を反映
const control = document.getElementById("wfm-feeder-control-after")?.value || "loop";
if (control === "fixed") {
// index を変えない
} else if (_lastSync) {
const indexEl = document.getElementById("wfm-feeder-index");
if (indexEl) indexEl.value = _lastSync.next_index;
if (control === "increment" && !_lastSync.has_next) {
_s.running = false;
showToast(`Feeder complete (${count} generated)`, "success");
break;
}
// loop: has_next=false でも次は index=0 からなので続行
}
}
} catch (err) {
if (_s.running) showToast("Feeder run error: " + err.message, "error");
} finally {
comfyUI.socket?.removeEventListener("message", _syncHandler);
_s.running = false;
_setRunUI(false);
if (progressText) progressText.textContent = `Done (${count} generated)`;
}
}
async function _stopRun() {
_s.running = false;
try { await comfyUI.interrupt(); } catch {}
_setRunUI(false);
showToast("Feeder stopped", "info");
}
// ── Folder Tree ───────────────────────────────────────────────────
async function _loadTree() {
const container = document.getElementById("wfm-feeder-tree");
if (!container) return;
container.innerHTML = `<div class="wfm-feeder-tree-msg">Loading...</div>`;
try {
const res = await fetch("/image_loop/tree");
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_renderTree(data.tree || [], container);
} catch (err) {
container.innerHTML = `<div class="wfm-feeder-tree-msg">Cannot reach image-loop API</div>`;
console.warn("[Feeder] tree load failed:", err);
}
}
function _renderTree(items, container, depth = 0) {
if (depth === 0) {
container.innerHTML = "";
container.appendChild(_makeTreeRow("", "🏠 (root)", 0));
}
for (const item of items) {
const icon = item.children?.length > 0 ? "📂" : "📁";
container.appendChild(_makeTreeRow(item.path, `${icon} ${item.name}`, depth + 1));
if (item.children?.length > 0) _renderTree(item.children, container, depth + 1);
}
}
function _makeTreeRow(path, label, depth) {
const row = document.createElement("div");
row.className = "wfm-feeder-tree-row" + (_s.dir === path ? " active" : "");
row.style.paddingLeft = `${8 + depth * 12}px`;
row.dataset.path = path;
row.textContent = label;
row.title = path || "(root)";
row.addEventListener("click", () => _selectDir(path));
return row;
}
function _selectDir(path) {
if (_s.dir !== path) {
_s.selected.clear();
_s.dir = path;
_setVal("wfm-feeder-dir-input", path);
}
_highlightTreeRow(path);
_loadImages(path);
_updateStatus();
}
function _highlightTreeRow(path) {
document.querySelectorAll(".wfm-feeder-tree-row").forEach(el => {
el.classList.toggle("active", el.dataset.path === path);
});
}
// ── Image Grid ────────────────────────────────────────────────────
async function _loadImages(dir) {
const grid = document.getElementById("wfm-feeder-grid");
if (!grid) return;
grid.innerHTML = `<div class="wfm-feeder-grid-msg">Loading...</div>`;
try {
const url = "/image_loop/images" + (dir ? `?dir=${encodeURIComponent(dir)}` : "");
const res = await fetch(url);
if (!res.ok) throw new Error(res.status);
const data = await res.json();
_s.images = data.images || [];
_renderGrid();
} catch (err) {
_s.images = [];
grid.innerHTML = `<div class="wfm-feeder-grid-msg" style="color:var(--wfm-danger)">Load failed: ${err.message}</div>`;
}
_updateStatus();
}
function _renderGrid() {
const grid = document.getElementById("wfm-feeder-grid");
if (!grid) return;
if (_s.images.length === 0) {
grid.innerHTML = `<div class="wfm-feeder-grid-msg">No images found</div>`;
return;
}
grid.innerHTML = "";
for (const fname of _s.images) grid.appendChild(_makeCard(fname));
}
function _makeCard(fname) {
const relPath = _s.dir ? `${_s.dir}/${fname}` : fname;
const checked = _s.selected.has(fname);
const card = document.createElement("div");
card.className = "wfm-feeder-card" + (checked ? " selected" : "");
card.dataset.name = fname;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "wfm-feeder-card-cb";
cb.checked = checked;
cb.addEventListener("change", e => {
e.stopPropagation();
if (cb.checked) _s.selected.add(fname);
else _s.selected.delete(fname);
card.classList.toggle("selected", cb.checked);
_updateStatus();
});
const img = document.createElement("img");
img.className = "wfm-feeder-card-img";
img.loading = "lazy";
img.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
img.onerror = () => { img.style.display = "none"; card.style.minHeight = "110px"; };
const name = document.createElement("div");
name.className = "wfm-feeder-card-name";
name.textContent = fname;
name.title = fname;
card.append(cb, img, name);
card.addEventListener("click", e => {
if (e.target === cb) return;
_showPreview(relPath, fname);
});
return card;
}
function _refreshGridCbs() {
document.querySelectorAll(".wfm-feeder-card").forEach(card => {
const sel = _s.selected.has(card.dataset.name);
card.classList.toggle("selected", sel);
const cb = card.querySelector("input[type=checkbox]");
if (cb) cb.checked = sel;
});
}
function _selectAll() {
_s.images.forEach(f => _s.selected.add(f));
_refreshGridCbs();
_updateStatus();
}
function _deselectAll() {
_s.images.forEach(f => _s.selected.delete(f));
_refreshGridCbs();
_updateStatus();
}
// ── Status Bar ───────────────────────────────────────────────────
function _updateStatus() {
const el = document.getElementById("wfm-feeder-status");
if (!el) return;
const inCurrent = _s.images.filter(f => _s.selected.has(f)).length;
el.textContent = `📂 ${_s.dir || "(root)"} | ${inCurrent} / ${_s.images.length} selected`;
}
// ── Preview Panel ────────────────────────────────────────────────
async function _showPreview(relPath, fname) {
const imgEl = document.getElementById("wfm-feeder-preview-img");
const nameEl = document.getElementById("wfm-feeder-preview-name");
const infoEl = document.getElementById("wfm-feeder-preview-info");
if (imgEl) {
imgEl.src = `/image_loop/thumbnail?path=${encodeURIComponent(relPath)}`;
imgEl.style.display = "block";
}
if (nameEl) nameEl.textContent = fname;
if (infoEl) infoEl.textContent = "Loading...";
try {
const res = await fetch(`/image_loop/image_info?path=${encodeURIComponent(relPath)}`);
if (res.ok && infoEl) {
const d = await res.json();
const kb = d.size_bytes ? (d.size_bytes / 1024).toFixed(1) + " KB" : "";
infoEl.textContent = `${d.width ?? "?"}×${d.height ?? "?"} px\n${kb}`;
}
} catch { if (infoEl) infoEl.textContent = ""; }
}
// ── Presets ──────────────────────────────────────────────────────
async function _loadPresets() {
try {
const res = await fetch("/image_feeder/presets");
if (!res.ok) return;
_s.presets = await res.json();
} catch { _s.presets = {}; }
_renderPresets();
}
function _renderPresets() {
const sel = document.getElementById("wfm-feeder-preset-sel");
if (!sel) return;
const names = Object.keys(_s.presets).sort();
sel.innerHTML = `<option value="">-- Select preset --</option>` +
names.map(n => `<option value="${n}">${n}</option>`).join("");
}
async function _savePreset() {
const name = document.getElementById("wfm-feeder-preset-name")?.value.trim();
if (!name) { showToast("Enter a preset name", "error"); return; }
const sel = _s.images.filter(f => _s.selected.has(f));
try {
const res = await fetch("/image_feeder/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, directory: _s.dir, selected_files: sel }),
});
if (res.ok) {
showToast(`Preset saved: ${name}`, "success");
const nameInput = document.getElementById("wfm-feeder-preset-name");
if (nameInput) nameInput.value = "";
await _loadPresets();
const selEl = document.getElementById("wfm-feeder-preset-sel");
if (selEl) selEl.value = name;
} else { showToast("Save failed", "error"); }
} catch (err) { showToast("Save error: " + err.message, "error"); }
}
async function _applyPreset() {
const selEl = document.getElementById("wfm-feeder-preset-sel");
const name = selEl?.value;
if (!name || !_s.presets[name]) { showToast("Select a preset first", "error"); return; }
const preset = _s.presets[name];
const dir = preset.directory || "";
_s.selected.clear();
(preset.selected_files || []).forEach(f => _s.selected.add(f));
_setVal("wfm-feeder-dir-input", dir);
if (_s.dir !== dir) {
_s.dir = dir;
_highlightTreeRow(dir);
await _loadImages(dir);
}
_refreshGridCbs();
_updateStatus();
showToast(`Preset loaded: ${name}`, "success");
}
async function _deletePreset() {
const name = document.getElementById("wfm-feeder-preset-sel")?.value;
if (!name) return;
try {
await fetch(`/image_feeder/presets/${encodeURIComponent(name)}`, { method: "DELETE" });
showToast(`Preset deleted: ${name}`, "info");
await _loadPresets();
} catch (err) { showToast("Delete error", "error"); }
}
// ── Helpers ───────────────────────────────────────────────────────
function _setVal(id, val) {
const el = document.getElementById(id);
if (el) el.value = val ?? "";
}
function _setCheck(id, val) {
const el = document.getElementById(id);
if (el) el.checked = !!val;
}
// ── Init ─────────────────────────────────────────────────────────
export async function initFeederTab() {
document.getElementById("wfm-feeder-node-sel")?.addEventListener("change", e => {
_loadFromNode(e.target.value);
});
document.getElementById("wfm-feeder-apply-btn")?.addEventListener("click", _applyToWorkflow);
document.getElementById("wfm-feeder-dir-input")?.addEventListener("change", e => {
const val = e.target.value.trim();
if (_s.dir !== val) {
_s.dir = val;
_highlightTreeRow(val);
_loadImages(val);
}
});
document.getElementById("wfm-feeder-reload-tree")?.addEventListener("click", _loadTree);
document.getElementById("wfm-feeder-sel-all")?.addEventListener("click", _selectAll);
document.getElementById("wfm-feeder-desel-all")?.addEventListener("click", _deselectAll);
document.getElementById("wfm-feeder-preset-save")?.addEventListener("click", _savePreset);
document.getElementById("wfm-feeder-preset-load")?.addEventListener("click", _applyPreset);
document.getElementById("wfm-feeder-preset-del")?.addEventListener("click", _deletePreset);
document.getElementById("wfm-feeder-run-btn")?.addEventListener("click", _startRun);
document.getElementById("wfm-feeder-stop-btn")?.addEventListener("click", _stopRun);
await Promise.all([_loadTree(), _loadPresets()]);
}
File diff suppressed because it is too large Load Diff
+657 -76
View File
@@ -8,6 +8,7 @@ import { comfyWorkflow } from "./comfyui-workflow.js";
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";
// ============================================
// Eagle Auto-Save
@@ -50,6 +51,42 @@ async function saveToEagle(imageUrl, name, tags = []) {
}
}
// ============================================
// Gallery Metadata - ワークフロー保存
// ============================================
let _outputDir = "";
async function _fetchOutputDir() {
try {
const res = await fetch("/api/wfm/settings/output-dir");
if (res.ok) {
const data = await res.json();
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
}
} catch {}
}
async function saveGeneratedImagesMeta(images, workflow) {
if (!_outputDir) await _fetchOutputDir();
if (!_outputDir) return;
for (const img of images) {
if (img.type !== "output") continue;
const parts = [_outputDir];
if (img.subfolder) parts.push(img.subfolder);
parts.push(img.filename);
const path = parts.join("/");
try {
await fetch("/wfm/gallery/image/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, workflow }),
});
} catch {}
}
}
// ============================================
// Status & Connection
// ============================================
@@ -109,12 +146,462 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
const genBtn = document.getElementById("wfm-gen-generate-btn");
if (genBtn) genBtn.disabled = !comfyUI.connected;
refreshFeederNodeList();
showToast(`Workflow loaded: ${filename || ""}`, "success");
return true;
}
// ============================================
// Generation
// Checkpoint Batch
// ============================================
const _ckptBatch = { aborted: false, paused: false, _resumeResolve: null };
async function _waitIfPaused() {
if (!_ckptBatch.paused) return;
await new Promise((resolve) => { _ckptBatch._resumeResolve = resolve; });
}
function _setPauseBtnState(paused) {
const btn = document.getElementById("wfm-ckpt-batch-pause-btn");
if (!btn) return;
btn.textContent = paused ? "Resume" : "Pause";
btn.style.background = paused ? "var(--wfm-success, #22c55e)" : "";
btn.style.color = paused ? "#fff" : "";
}
// mode: "all" = 全選択, "some" = 一部選択, "none" = 全解除
const _ckptState = { mode: "all", selected: new Set() };
function _getSelectedCheckpoints() {
const all = comfyEditor.models.checkpoints || [];
if (_ckptState.mode === "all") return all;
if (_ckptState.mode === "none") return [];
return all.filter((m) => _ckptState.selected.has(m));
}
function _updateDropdownLabel() {
const labelEl = document.getElementById("wfm-ckpt-dropdown-label");
if (!labelEl) return;
const total = (comfyEditor.models.checkpoints || []).length;
if (_ckptState.mode === "all") {
labelEl.textContent = total > 0 ? `All (${total})` : "All checkpoints";
} else if (_ckptState.mode === "none") {
labelEl.textContent = `0 / ${total} selected`;
} else {
labelEl.textContent = `${_ckptState.selected.size} / ${total} selected`;
}
}
// モデルパスリストをフォルダ → [modelPath, ...] のMapに変換
function _buildFolderTree(models) {
const map = new Map();
for (const m of models) {
const normalized = m.replace(/\\/g, "/");
const lastSlash = normalized.lastIndexOf("/");
const folder = lastSlash === -1 ? "" : normalized.substring(0, lastSlash);
if (!map.has(folder)) map.set(folder, []);
map.get(folder).push(m);
}
return new Map([...map.entries()].sort((a, b) => {
if (a[0] === b[0]) return 0;
if (a[0] === "") return -1;
if (b[0] === "") return 1;
return a[0].localeCompare(b[0]);
}));
}
// フォルダ内の選択状態を返す: "checked" | "indeterminate" | "unchecked"
function _getFolderCheckState(folderModels) {
if (_ckptState.mode === "all") return "checked";
if (_ckptState.mode === "none") return "unchecked";
const selCount = folderModels.filter((m) => _ckptState.selected.has(m)).length;
if (selCount === 0) return "unchecked";
if (selCount === folderModels.length) return "checked";
return "indeterminate";
}
// 単一モデルの選択トグル(_ckptState を更新)
function _toggleSingleModel(m, checked, all) {
if (_ckptState.mode === "all" && !checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
all.forEach((x) => _ckptState.selected.add(x));
} else if (_ckptState.mode === "none" && checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
}
if (checked) _ckptState.selected.add(m);
else _ckptState.selected.delete(m);
if (_ckptState.selected.size === all.length) { _ckptState.mode = "all"; _ckptState.selected.clear(); }
if (_ckptState.mode === "some" && _ckptState.selected.size === 0) _ckptState.mode = "none";
}
// フォルダ単位の一括トグル(_ckptState を更新)
function _toggleFolderModels(folderModels, checked, all) {
if (_ckptState.mode === "all" && !checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
all.forEach((x) => _ckptState.selected.add(x));
} else if (_ckptState.mode === "none" && checked) {
_ckptState.mode = "some";
_ckptState.selected.clear();
}
folderModels.forEach((m) => { if (checked) _ckptState.selected.add(m); else _ckptState.selected.delete(m); });
if (_ckptState.selected.size === all.length) { _ckptState.mode = "all"; _ckptState.selected.clear(); }
if (_ckptState.mode === "some" && _ckptState.selected.size === 0) _ckptState.mode = "none";
}
function _rebuildCkptList() {
const listEl = document.getElementById("wfm-ckpt-list");
if (!listEl) return;
const all = comfyEditor.models.checkpoints || [];
const search = document.getElementById("wfm-ckpt-search")?.value.toLowerCase() || "";
const filtered = search ? all.filter((m) => m.toLowerCase().includes(search)) : all;
if (filtered.length === 0) {
listEl.innerHTML = `<div style="padding:8px 10px;font-size:11px;color:var(--wfm-text-secondary);">No checkpoints</div>`;
return;
}
const folderTree = _buildFolderTree(filtered);
listEl.innerHTML = "";
for (const [folder, models] of folderTree) {
const group = document.createElement("div");
group.className = "wfm-ckpt-folder-group open";
// --- フォルダヘッダー ---
const header = document.createElement("div");
header.className = "wfm-ckpt-folder-header";
const folderCb = document.createElement("input");
folderCb.type = "checkbox";
const folderState = _getFolderCheckState(models);
folderCb.checked = folderState === "checked";
folderCb.indeterminate = folderState === "indeterminate";
const toggle = document.createElement("span");
toggle.className = "wfm-ckpt-folder-toggle";
toggle.textContent = "▶";
const nameSpan = document.createElement("span");
nameSpan.className = "wfm-ckpt-folder-name";
nameSpan.textContent = folder || "(root)";
nameSpan.title = folder || "(root)";
const countSpan = document.createElement("span");
countSpan.className = "wfm-ckpt-folder-count";
countSpan.textContent = models.length;
// --- ファイルリスト ---
const filesDiv = document.createElement("div");
filesDiv.className = "wfm-ckpt-folder-files";
for (const m of models) {
const itemLabel = document.createElement("label");
itemLabel.className = "wfm-ckpt-item wfm-ckpt-item--indented";
const fileCb = document.createElement("input");
fileCb.type = "checkbox";
fileCb.value = m;
if (_ckptState.mode === "all") fileCb.checked = true;
else if (_ckptState.mode === "none") fileCb.checked = false;
else fileCb.checked = _ckptState.selected.has(m);
const normalized = m.replace(/\\/g, "/");
const lastSlash = normalized.lastIndexOf("/");
const fileName = lastSlash === -1 ? m : m.substring(lastSlash + 1);
const fileSpan = document.createElement("span");
fileSpan.className = "wfm-ckpt-item-label";
fileSpan.textContent = fileName;
fileSpan.title = m;
fileCb.addEventListener("change", () => {
_toggleSingleModel(m, fileCb.checked, all);
const s = _getFolderCheckState(models);
folderCb.checked = s === "checked";
folderCb.indeterminate = s === "indeterminate";
_updateDropdownLabel();
_updateBatchInfo();
});
itemLabel.appendChild(fileCb);
itemLabel.appendChild(fileSpan);
filesDiv.appendChild(itemLabel);
}
// フォルダCBのchange
folderCb.addEventListener("change", () => {
_toggleFolderModels(models, folderCb.checked, all);
folderCb.indeterminate = false;
filesDiv.querySelectorAll("input[type=checkbox]").forEach((cb) => { cb.checked = folderCb.checked; });
_updateDropdownLabel();
_updateBatchInfo();
});
// フォルダ名 / トグル矢印クリックで展開・折りたたみ
[toggle, nameSpan].forEach((el) => {
el.addEventListener("click", () => group.classList.toggle("open"));
});
header.appendChild(folderCb);
header.appendChild(toggle);
header.appendChild(nameSpan);
header.appendChild(countSpan);
group.appendChild(header);
group.appendChild(filesDiv);
listEl.appendChild(group);
}
}
function _updateBatchInfo() {
const infoEl = document.getElementById("wfm-ckpt-batch-info");
if (!infoEl) return;
const n = _getSelectedCheckpoints().length;
infoEl.textContent = `${n} checkpoint${n !== 1 ? "s" : ""} will be processed`;
}
function initCheckpointBatch() {
const checkbox = document.getElementById("wfm-ckpt-batch-enabled");
const body = document.getElementById("wfm-ckpt-batch-body");
const wrap = document.getElementById("wfm-ckpt-dropdown-wrap");
const btn = document.getElementById("wfm-ckpt-dropdown-btn");
const panel = document.getElementById("wfm-ckpt-dropdown-panel");
checkbox?.addEventListener("change", () => {
if (body) body.style.display = checkbox.checked ? "block" : "none";
if (checkbox.checked) {
_updateDropdownLabel();
_updateBatchInfo();
}
});
// ドロップダウン開閉
btn?.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = panel.style.display !== "none";
if (isOpen) {
panel.style.display = "none";
wrap.classList.remove("open");
} else {
_rebuildCkptList();
panel.style.display = "block";
wrap.classList.add("open");
document.getElementById("wfm-ckpt-search")?.focus();
}
});
// 外側クリックで閉じる
document.addEventListener("click", (e) => {
if (panel && panel.style.display !== "none" && !wrap?.contains(e.target)) {
panel.style.display = "none";
wrap?.classList.remove("open");
}
});
// 検索フィルター
document.getElementById("wfm-ckpt-search")?.addEventListener("input", _rebuildCkptList);
// 全選択
document.getElementById("wfm-ckpt-select-all")?.addEventListener("click", () => {
_ckptState.mode = "all";
_ckptState.selected.clear();
_rebuildCkptList();
_updateDropdownLabel();
_updateBatchInfo();
});
// 全解除
document.getElementById("wfm-ckpt-deselect-all")?.addEventListener("click", () => {
_ckptState.mode = "none";
_ckptState.selected.clear();
_rebuildCkptList();
_updateDropdownLabel();
_updateBatchInfo();
});
// Pause / Resume
document.getElementById("wfm-ckpt-batch-pause-btn")?.addEventListener("click", () => {
if (!_ckptBatch.paused) {
_ckptBatch.paused = true;
_setPauseBtnState(true);
} else {
_ckptBatch.paused = false;
_setPauseBtnState(false);
if (_ckptBatch._resumeResolve) {
_ckptBatch._resumeResolve();
_ckptBatch._resumeResolve = null;
}
}
});
}
// ============================================
// Generation (core — throws on error)
// ============================================
async function _coreGenerate(silent = false) {
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
}
}
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
if (images.length > 0) {
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
}
if (!silent) showToast("Generation complete", "success");
}
// ============================================
// Batch generation loop
// ============================================
async function _runBatchGenerate() {
const ckptNodes = comfyUI.currentAnalysis?.checkpoint_nodes || [];
if (ckptNodes.length === 0) {
showToast("No checkpoint node found in workflow", "error");
return;
}
const list = _getSelectedCheckpoints();
if (list.length === 0) {
showToast("No checkpoints selected", "error");
return;
}
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");
const batchBar = document.getElementById("wfm-ckpt-batch-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
if (batchProgress) batchProgress.style.display = "block";
const pauseBtn = document.getElementById("wfm-ckpt-batch-pause-btn");
if (pauseBtn) { pauseBtn.style.display = "block"; pauseBtn.disabled = false; }
_setPauseBtnState(false);
let completed = 0;
let failed = 0;
try {
for (let i = 0; i < list.length; i++) {
if (_ckptBatch.aborted) break;
// 一時停止中は次のモデルへ進む前に待機
if (_ckptBatch.paused) {
if (batchCurrentName) batchCurrentName.textContent = "Paused...";
if (progressText) progressText.textContent = "Paused";
}
await _waitIfPaused();
if (_ckptBatch.aborted) break;
const model = list[i];
if (batchCurrentName) batchCurrentName.textContent = model;
if (batchCount) batchCount.textContent = `${i + 1} / ${list.length}`;
if (batchBar) batchBar.style.width = `${((i / list.length) * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `[${i + 1}/${list.length}] Loading...`;
for (const node of ckptNodes) {
if (comfyUI.currentWorkflow?.[node.id]) {
comfyUI.currentWorkflow[node.id].inputs.ckpt_name = model;
}
}
try {
await _coreGenerate(true);
completed++;
} catch (err) {
if (_ckptBatch.aborted) break;
failed++;
showToast(`[${i + 1}/${list.length}] Failed: ${err.message}`, "error");
}
}
} finally {
if (pauseBtn) pauseBtn.disabled = true;
_ckptBatch.paused = false;
if (_ckptBatch._resumeResolve) {
_ckptBatch._resumeResolve();
_ckptBatch._resumeResolve = null;
}
_setPauseBtnState(false);
}
if (batchBar) batchBar.style.width = "100%";
if (batchCurrentName) batchCurrentName.textContent = _ckptBatch.aborted ? "Stopped" : "Done";
if (_ckptBatch.aborted) {
showToast(`Batch stopped — ${completed} completed, ${failed} failed`, "info");
} else {
showToast(
`Batch complete: ${completed}/${list.length}${failed > 0 ? ` (${failed} failed)` : ""}`,
failed > 0 ? "error" : "success"
);
}
}
// ============================================
// Generate entry point
// ============================================
async function handleGenerate() {
@@ -124,84 +611,31 @@ async function handleGenerate() {
}
if (comfyUI.generating) return;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
// Sync prompts to workflow
comfyEditor.syncToWorkflow();
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
genBtn.disabled = true;
if (interruptBtn) interruptBtn.style.display = "inline-block";
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
_ckptBatch.aborted = false;
_ckptBatch.paused = false;
_ckptBatch._resumeResolve = null;
const batchEnabled = document.getElementById("wfm-ckpt-batch-enabled")?.checked;
try {
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
// Update seed display
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
// Display results
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
// Thumbnails for multiple images
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
if (batchEnabled) {
await _runBatchGenerate();
} else {
try {
await _coreGenerate(false);
} catch (err) {
const progressText = document.getElementById("wfm-gen-progress-text");
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
}
}
// Eagle auto-save
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
showToast("Generation complete", "success");
} catch (err) {
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
} finally {
genBtn.disabled = false;
if (interruptBtn) interruptBtn.style.display = "none";
@@ -235,17 +669,41 @@ export async function initGenerateTab() {
comfyEditor.renderAll(comfyUI.currentAnalysis, comfyUI.currentWorkflow);
}
showToast("Model lists refreshed", "success");
_updateDropdownLabel();
_updateBatchInfo();
});
// Generate button
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", handleGenerate);
// Interrupt button
// Interrupt button (stops both single generation and batch loop)
document.getElementById("wfm-gen-interrupt-btn")?.addEventListener("click", async () => {
_ckptBatch.aborted = true;
// 一時停止中でもループを抜けられるよう待機を解除
_ckptBatch.paused = false;
if (_ckptBatch._resumeResolve) {
_ckptBatch._resumeResolve();
_ckptBatch._resumeResolve = null;
}
await comfyUI.interrupt();
showToast("Interrupted", "info");
});
// Move shared Raw JSON widget into the active tab's rawjson-col
function moveRawJsonToTab(tabKey) {
const widget = document.getElementById("wfm-gen-rawjson-widget");
if (!widget) return;
if (tabKey === "feeder") {
widget.style.display = "none";
return;
}
const col = document.getElementById(`wfm-gen-rawjson-col-${tabKey}`);
if (col) {
col.appendChild(widget);
widget.style.display = "flex";
}
}
// Subtab navigation
document.querySelectorAll(".wfm-gen-subtab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -254,10 +712,14 @@ export async function initGenerateTab() {
btn.classList.add("active");
document.querySelectorAll(".wfm-gen-subtab-content").forEach((c) => c.classList.remove("active"));
document.getElementById(`wfm-gen-subtab-${target}`)?.classList.add("active");
moveRawJsonToTab(target);
});
});
// Raw JSON apply
// Initial placement: move to first active tab (input)
moveRawJsonToTab("input");
// Raw JSON apply (always-visible panel in right column)
document.getElementById("wfm-gen-apply-raw-btn")?.addEventListener("click", async () => {
const textarea = document.getElementById("wfm-gen-raw-json");
if (!textarea) return;
@@ -269,14 +731,119 @@ export async function initGenerateTab() {
}
});
// Raw JSON highlight sync on input/scroll
// Raw JSON highlight sync + search
{
const editor = document.getElementById("wfm-gen-raw-json");
const highlight = document.getElementById("wfm-gen-raw-json-highlight");
if (editor && highlight) {
editor.addEventListener("input", () => syncJsonHighlight(highlight, editor.value));
editor.addEventListener("scroll", () => syncScroll(editor, highlight));
const searchOverlay = document.getElementById("wfm-gen-raw-json-search-overlay");
const searchInput = document.getElementById("wfm-gen-raw-search");
const searchCount = document.getElementById("wfm-gen-raw-search-count");
const searchPrev = document.getElementById("wfm-gen-raw-search-prev");
const searchNext = document.getElementById("wfm-gen-raw-search-next");
const searchClear = document.getElementById("wfm-gen-raw-search-clear");
let currentMatchIndex = 0;
let matchPositions = [];
function updateSearchOverlay() {
if (!editor || !searchOverlay || !searchInput) return;
const term = searchInput.value;
if (!term) {
searchOverlay.innerHTML = "";
if (searchCount) { searchCount.textContent = ""; searchCount.style.color = ""; }
matchPositions = [];
return;
}
const text = editor.value;
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedTerm, "gi");
matchPositions = [];
let m;
while ((m = regex.exec(text)) !== null) matchPositions.push(m.index);
if (matchPositions.length === 0) {
searchOverlay.innerHTML = "";
if (searchCount) { searchCount.textContent = "No results"; searchCount.style.color = "#e06c75"; }
return;
}
if (searchCount) searchCount.style.color = "";
if (currentMatchIndex >= matchPositions.length) currentMatchIndex = 0;
if (searchCount) searchCount.textContent = `${currentMatchIndex + 1}/${matchPositions.length}`;
const escapedHtml = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
let matchIdx = 0;
const html = escapedHtml.replace(new RegExp(escapedTerm, "gi"), (match) => {
const cls = matchIdx === currentMatchIndex ? "wfm-search-current" : "wfm-search-match";
matchIdx++;
return `<mark class="${cls}">${match}</mark>`;
});
searchOverlay.innerHTML = html + "\n";
// Scroll editor to current match
const pos = matchPositions[currentMatchIndex];
editor.focus();
editor.setSelectionRange(pos, pos + term.length);
const lineNum = (text.slice(0, pos).match(/\n/g) || []).length;
const lineH = parseFloat(getComputedStyle(editor).lineHeight) || 18;
editor.scrollTop = Math.max(0, lineH * lineNum - editor.clientHeight / 2);
searchOverlay.scrollTop = editor.scrollTop;
searchOverlay.scrollLeft = editor.scrollLeft;
if (highlight) highlight.scrollTop = editor.scrollTop;
}
if (editor && highlight) {
editor.addEventListener("input", () => {
syncJsonHighlight(highlight, editor.value);
if (searchInput?.value) updateSearchOverlay();
});
editor.addEventListener("scroll", () => {
syncScroll(editor, highlight);
if (searchOverlay) {
searchOverlay.scrollTop = editor.scrollTop;
searchOverlay.scrollLeft = editor.scrollLeft;
}
});
}
searchInput?.addEventListener("input", () => { currentMatchIndex = 0; updateSearchOverlay(); });
searchNext?.addEventListener("click", () => {
if (!matchPositions.length) return;
currentMatchIndex = (currentMatchIndex + 1) % matchPositions.length;
updateSearchOverlay();
});
searchPrev?.addEventListener("click", () => {
if (!matchPositions.length) return;
currentMatchIndex = (currentMatchIndex - 1 + matchPositions.length) % matchPositions.length;
updateSearchOverlay();
});
function clearSearch() {
if (searchInput) searchInput.value = "";
currentMatchIndex = 0;
matchPositions = [];
if (searchOverlay) searchOverlay.innerHTML = "";
if (searchCount) { searchCount.textContent = ""; searchCount.style.color = ""; }
}
searchClear?.addEventListener("click", clearSearch);
searchInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
if (!matchPositions.length) return;
currentMatchIndex = e.shiftKey
? (currentMatchIndex - 1 + matchPositions.length) % matchPositions.length
: (currentMatchIndex + 1) % matchPositions.length;
updateSearchOverlay();
e.preventDefault();
} else if (e.key === "Escape") {
clearSearch();
}
});
}
// Check for workflow loaded from Workflow tab (via sessionStorage)
@@ -289,11 +856,25 @@ export async function initGenerateTab() {
} catch {}
}
// outputDir を事前取得(生成後の保存に使用)
_fetchOutputDir();
window.addEventListener("wfm-output-dir-changed", (e) => {
_outputDir = (e.detail?.path || "").replace(/\\/g, "/").replace(/\/$/, "");
});
// Checkpoint batch UI
initCheckpointBatch();
// Feeder tab
await initFeederTab();
// Auto-connect on init
const connected = await comfyUI.checkConnection();
updateStatus(connected);
if (connected) {
await comfyEditor.loadModelLists();
_updateDropdownLabel();
_updateBatchInfo();
}
// Auto-load default workflow (doesn't require ComfyUI connection)
+600 -45
View File
@@ -20,6 +20,7 @@ const LANGUAGES = {
// -- Tabs --
tabWorkflow: "Workflow",
tabGenerate: "GenerateUI",
tabGallery: "Gallery",
tabSettings: "Settings",
// -- Workflow toolbar --
@@ -31,6 +32,13 @@ const LANGUAGES = {
import: "Import",
badgeColors: "Badge Colors",
resetColors: "Reset",
badgeManage: "Manage Badges",
badgeAdd: "Add",
badgeDelete: "Delete",
badgeNone: "No badges defined yet.",
badgeNoneHint: "No badges defined. Click \u2699 Manage Badges to add.",
badgeNewLabel: "Badge label...",
badgeColorHint: "Change color",
defaultView: "Default View",
viewThumbnail: "Thumbnail",
viewCard: "Card",
@@ -160,6 +168,10 @@ const LANGUAGES = {
tabNodes: "Nodes",
nodesAllNodes: "All Nodes",
nodesNodeSets: "Node Sets",
nodesCategoryTab: "Category",
nodesPackageTab: "Package",
nodesSelectCategory: "Select a category...",
nodesSelectPackage: "Select a package...",
nodesSearchPlaceholder: "Search nodes...",
nodesAllCategories: "All Categories",
nodesAllPackages: "All Packages",
@@ -225,11 +237,18 @@ const LANGUAGES = {
modelsTags: "Tags",
modelsTagsPlaceholder: "tag1, tag2, ...",
modelsModelType: "Model Type",
modelsBadges: "Badges",
modelsInfo: "Info",
modelsFavorite: "Favorite",
modelsMemo: "Memo",
modelsMemoPlaceholder: "Notes about this model...",
modelsSave: "Save",
modelsSaved: "Metadata saved",
modelsGenUIBtn: "GenUI Model",
modelsGenUITitle: "Set this model in GenerateUI",
modelsGenUIUnsupported: "This model type cannot be set in GenerateUI",
modelsGenUINoWorkflow: "No workflow loaded in GenerateUI",
modelsGenUINoNode: (type) => `No ${type} node found in current workflow`,
modelsCopyName: "Click to copy model name",
modelsCopiedName: "Model name copied",
modelsFilePath: "File Path",
@@ -237,6 +256,7 @@ const LANGUAGES = {
modelsCopiedPath: "File path copied",
modelsNoPreview: "No preview",
modelsTagsHint: "(comma separated)",
modelsAllDirs: "All Folders",
modelsAllGroups: "All Groups",
modelsCurrentGroups: "Current Groups",
modelsNoGroup: "Not assigned to any group",
@@ -253,6 +273,28 @@ const LANGUAGES = {
modelsGroupExists: "Group already exists",
modelsRenamePrompt: "Enter new group name:",
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
modelEnable: "Enable",
modelDisable: "Disable",
modelDisabled: "Disabled",
modelStatusAll: "All",
modelStatusEnabled: "Enabled only",
modelStatusDisabled: "Disabled only",
modelGroupEnableAll: "Enable All",
modelGroupDisableAll: "Disable All",
modelToggleError: "Failed to toggle model",
modelStatusWarning: "Changes take effect after ComfyUI refresh",
modelSelectMode: "Select",
modelSelectExit: "Exit Select",
modelSelected: "selected",
modelBulkAddGroup: "Add to Group",
modelBulkRemoveGroup: "Remove from Group",
modelBulkCreateAdd: "Create & Add",
modelBulkAddDone: "models added to group",
modelBulkRemoveDone: "models removed from group",
modelBulkDelete: "Delete Files",
modelBulkDeleteConfirm: "Permanently delete {count} model file(s) and their preview images? This cannot be undone.",
modelBulkDeleteDone: "model file(s) deleted",
modelBulkDeleteError: "Failed to delete some models",
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
civitaiFetch: "Fetch from CivitAI",
civitaiHashing: "Calculating hash... (may take a while for large files)",
@@ -270,6 +312,63 @@ const LANGUAGES = {
modelsSideInfo: "Info",
modelsSideGroup: "Group",
modelsSideCivitai: "CivitAI",
gallerySideMetadata: "Metadata",
// -- Gallery --
galleryAllTags: "All Tags",
galleryFolders: "Folders",
gallerySelectFolder: "Select a folder to browse images.",
galleryNoImages: "No images found.",
gallerySearchPlaceholder: "Search images...",
galleryOutputDir: "Gallery Output Folder",
galleryOutputDirLabel: "Output Folder Path",
galleryOutputDirHint: "Set the ComfyUI output folder to browse in the Gallery tab. Leave blank to use ComfyUI's default output directory.",
tabAi: "AI TOOL",
// -- AI Tab --
aiSubTabTranslate: "Translate",
aiSubTabVlm: "VLM",
aiSubTabSettings: "Settings",
aiLangJa: "Japanese", aiLangEn: "English", aiLangZh: "Chinese", aiLangFree: "Free",
aiTransInputPlaceholder: "Enter text to translate...",
aiTransOutputPlaceholder: "Translation result will appear here...",
aiTranslateBtn: "Translate",
aiCopyBtn: "Copy",
aiVlmDropLabel: "Drop PNG / JPG / WebP here or click",
aiVlmDescribe: "Describe Image",
aiVlmPromptCreate: "Create Prompt",
aiVlmRunBtn: "Run",
aiVlmResultPlaceholder: "Result will appear here...",
aiSettingsBackend: "Backend",
aiSettingsConnection: "Connection",
aiSettingsTestBtn: "Test Connection",
aiSettingsModelSection: "Model",
aiSettingsRefreshBtn: "Refresh",
aiSettingsModelPlaceholder: "-- Select Model --",
aiSettingsFreeLang: "Free Language",
aiSettingsInputLang: "Input",
aiSettingsOutputLang: "Output",
aiSettingsSaveBtn: "Save Settings",
aiToastNoText: "Please enter text",
aiToastNoModel: "Please select a model in the Settings tab",
aiToastInvalidUrl: "URL must start with http:// or https://",
aiToastInvalidUrlInput: "Please enter a URL starting with http:// or https://",
aiStatusTranslating: "Translating...",
aiStatusRunning: "Running...",
aiStatusDone: "Done",
aiStatusConnecting: "Connecting...",
aiStatusConnectOk: "Connected",
aiStatusConnectFail: "Connection failed: ",
aiToastTransFailed: "Translation failed: ",
aiToastNoCopyText: "No text to copy",
aiToastCopied: "Copied",
aiToastNoModels: "No models found",
aiToastModelsFailed: "Failed to fetch models: ",
aiToastSettingsSaved: "Settings saved",
aiToastNoImage: "Please drop an image",
aiToastVlmFailed: "VLM failed: ",
aiModels: "models",
// -- Help & Support --
tabHelp: "Help",
@@ -283,16 +382,30 @@ const LANGUAGES = {
helpWf3: "Create, rename, delete groups and assign/remove workflows",
helpWf4: "Import JSON/PNG files via button or drag & drop",
helpWf5: "Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management",
helpWf6: "Detail modal: rename, favorite, thumbnail change, analysis, category override, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete",
helpWf7: "Customize badge colors per category",
helpWf8: "Batch re-analysis of all workflow I/O (P: prompt nodes, I: image nodes, output: save nodes)",
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete",
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
helpGen1: "Connect/disconnect ComfyUI and check connection status",
helpGen2: "Load workflows from Workflow tab (App format .app.json not supported)",
helpGen3: "Edit parameters: Prompt, Image (drag & drop), Model (checkpoint/VAE/LoRA/ControlNet with filter), Settings (steps/CFG/sampler/scheduler/resolution), Raw JSON",
helpGen4: "Seed control: random, fixed, increment, decrement",
helpGen5: "Generate, progress display, and stop",
helpGen6: "View results with main image and thumbnail history",
helpGen7: "Eagle integration: auto-save generated images to Eagle",
helpGen3: "4-tab layout — Input (Prompt + Image), Model, Settings, Feeder — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing",
helpGen4: "Input tab: Prompt (top) and Image drag-and-drop (bottom) in the left column",
helpGen5: "Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter",
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow",
helpGen8: "Seed control: random, fixed, increment, decrement",
helpGen9: "Generate, progress display, stop, and view results",
helpGen10: "Eagle integration: auto-save generated images to Eagle",
helpGen11: "Checkpoint Batch: select checkpoints by folder from the dropdown — check a folder to select all its files, or expand with ▶ to check individual files; use Filter to search; Pause/Resume suspends and continues between models; Stop aborts after the current generation",
helpFeederTitle: "GenerateUI Tab — Feeder subtab",
helpFeederDesc: "Requires the comfyui-image-feeder custom node. Controls ImageFeeder nodes and runs continuous generation loops over an image library stored in user/default/image-loop-data/.",
helpFeeder1: "Left pane — Node settings: select the target ImageFeeder node from the dropdown; edit all node parameters (Directory, Sort Mode, Index, Start/End Index, Batch Size, Seed, Use Selection); click Apply to write changes to the loaded workflow",
helpFeeder2: "Left pane — Presets: save the current directory + selection as a named preset; load or delete saved presets",
helpFeeder3: "Left pane — Run: After gen mode determines index behavior after each generation — Loop (wrap back to 0 and continue indefinitely), Increment (advance index and stop when all images are exhausted), Fixed (always use the same index); click Run to start the loop, Stop to interrupt",
helpFeeder4: "Center pane — Folder tree: browse subfolders inside image-loop-data; click a folder to load its images; click ↺ to reload the tree",
helpFeeder5: "Center pane — Image grid: displays thumbnails of all images in the selected folder; check individual images to add them to the selection; All / None buttons select or clear the entire folder; click an image to open the preview panel",
helpFeeder6: "Center pane — Preview panel: shows the clicked image at larger size with filename, resolution, and file size",
helpFeeder7: "Status bar: shows the current folder path and how many images are selected out of the total in the folder",
helpFeeder8: "Seed during Run: uses the right-pane seed setting (Random / Fixed / Increment / Decrement) — the ImageFeeder's own Seed field controls random sort order only",
helpFeeder9: "Index sync: after each generation the node returns the next index via WebSocket; the Index field updates automatically so the display stays in sync",
helpPrompt1: "AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)",
helpPrompt2: "Apply AI responses to GenerateUI prompts",
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
@@ -300,6 +413,8 @@ const LANGUAGES = {
helpPrompt5: "Preset Manager: browse all presets, favorites, and group-based filtering",
helpPrompt6: "Group management: create groups, assign/remove presets, delete groups",
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
helpPrompt8: "Wildcard toolbar: one-click buttons to insert wildcard syntax ({|}, {n$|}, __|__, <lora:>, etc.); wraps selected text",
helpPrompt9: "Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor",
helpSettings1: "UI language and summary language",
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
helpSettings3: "Workflow data folder path",
@@ -307,24 +422,42 @@ const LANGUAGES = {
helpSettings5: "Ollama URL, default model, and connection test",
helpSettings6: "Default workflow setting",
helpSettings7: "Eagle integration URL and auto-save toggle",
helpSettings8: "All sections are collapsible accordion panels",
helpSettings8: "Data Management: export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import to restore. After importing, restart ComfyUI.",
helpSettings9: "All sections are collapsible accordion panels",
helpSettings10: "Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (directory junction on Windows / symlink on other OS); existing files migrated automatically",
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
helpNodes2: "Card and Table view modes with pagination",
helpNodes3: "Favorite nodes and add custom tags for organization",
helpNodes4: "Create, rename, delete node groups and assign/remove nodes",
helpNodes5: "Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management",
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder with sub-tab switching",
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching",
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
helpModels3: "Search, tag filter, group filter, and favorites filter",
helpModels3: "Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)",
helpModels4: "Side panel: Info (file path, tags, memo), Groups management, CivitAI integration",
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type",
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type; preview image is automatically downloaded and saved if none exists",
helpModels6: "Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change",
helpModels7: "Group management: create, rename, delete groups and assign/remove models",
helpModels8: "GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "User-defined badges: assign free-label badges with custom colors shared with the Workflow tab",
helpModels10: "Enable/Disable models: toggle visibility in ComfyUI by renaming file extension (.disabled suffix). Disable per model (⏸ button on each card) or per group (Enable All / Disable All in Group panel). Changes take effect after ComfyUI refresh.",
helpModels11: "Multi-select & bulk operations: click Select to enter selection mode, check multiple models, then use the bulk action bar to add/remove from groups or permanently delete files (model file + preview images + sidecar files such as .json/.info).",
helpGalleryTitle: "Gallery Tab",
helpGallery1: "Browse images in the ComfyUI output folder — set the folder path in Settings > Gallery Output Folder",
helpGallery2: "3-column layout: folder tree (left) | image grid (center) | detail panel (right)",
helpGallery3: "Thumbnail and Table view modes; double-click any image to open fullscreen lightbox",
helpGallery4: "Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name",
helpGallery5: "Folder tree: click to load images, expand with ▶; '+ New' creates a subfolder inside the selected folder, 'Del' deletes it with all contents",
helpGallery6: "Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All, Move To..., Delete",
helpGallery7: "Detail panel — Info tab: filename, size, date, tags (add/remove), memo; 'Move To...' moves the image to another folder, 'Delete' removes it permanently",
helpGallery8: "Detail panel — Metadata tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; one-click clipboard copy",
helpGallery9: "Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image",
helpGallery10: "Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the Metadata tab",
helpGallery11: "Supports PNG (tEXt/iTXt), JPEG, WebP, GIF; workflow extraction reads ComfyUI prompt and workflow keys embedded in PNG metadata",
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
helpSidepanel2: "Workflows tab: browse favorite workflows, filter by model type, and filter by group",
helpSidepanel3: "Nodes tab: browse favorite nodes, node sets, and node groups",
helpSidepanel2: "Workflows tab: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view",
helpSidepanel3: "Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes by category or custom node package",
helpSidepanel4: "Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs",
helpSidepanel5: "Drag & drop workflows onto the canvas to load them",
helpSidepanel6: "Drag & drop nodes/node sets onto the canvas to place them",
@@ -332,6 +465,19 @@ const LANGUAGES = {
helpSidepanel8: "Copy individual positive (P) or negative (N) prompts from sidebar items",
helpSidepanel9: "Double-click items to load/place without dragging",
helpSidepanel10: "Search within each sub-tab to quickly find items",
helpSidepanel11: "Models tab: All / ★ Favorites / Groups / By Type sub-tabs",
helpSidepanel12: "⚙ Theme button (header): customize panel background, sub-header background, text, border, and secondary text colors — changes are saved to localStorage and applied on next open",
helpSidepanel13: "I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF and QuadrupleCLIPLoader node types",
helpSidepanel14: "Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center",
helpSidepanel15: "Information tab — LoRA sub-tab: drag individual LoRA to place LoraLoader; \"Multiple LORA\" section at bottom places all LoRAs in a single Lora Loader (LoraManager) node; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy",
helpSidepanel16: "A tab (AI Tools): Translation and VLM sub-tabs powered by Ollama or LM Studio; settings (backend, URL, model) are shared with the SPA AI tab via localStorage",
helpAiTitle: "AI Tab (A)",
helpAi1: "Translation sub-tab: translate text between Japanese, English, Chinese, or a custom Free language using Ollama or LM Studio",
helpAi2: "Language selectors for source and destination with ⇄ swap button; text content is also swapped; selections are saved automatically",
helpAi3: "TOOLS pane: drop an image into the drop zone and select a task — Describe Image, Create Prompt, or Create Tags — then click Run to analyze with a vision model",
helpAi4: "VLM result is displayed in the output area with a Copy button",
helpAi5: "Settings sub-tab: choose backend (Ollama / LM Studio), set the API URL, test connection, select model, and configure Free language names for translation",
helpAi6: "Settings are saved to localStorage and shared with the Library panel's A tab",
helpShortcutsTitle: "Keyboard Shortcuts",
helpShortcuts1: "Escape: Close modal/panel",
helpShortcuts2: "Enter: Confirm rename, send chat",
@@ -367,7 +513,7 @@ const LANGUAGES = {
comfyuiUrlHint: "When running as ComfyUI plugin, leave empty to use same origin. Only set if ComfyUI is on a different server.",
connectedCheck: "Connected \u2713",
failedConnect: "Failed to connect \u2717",
ollamaSettings: "Ollama Settings",
ollamaSettings: "Ollama Settings (Prompt Tab)",
ollamaUrl: "Ollama API URL",
ollamaDefaultModel: "Default Model",
selectModel: "Select model...",
@@ -385,6 +531,14 @@ const LANGUAGES = {
eagleIntegration: "Eagle Integration (Optional)",
eagleUrl: "Eagle URL",
eagleAutoSave: "Auto-save generated images to Eagle",
dataManagement: "Data Management",
dataManagementHint: "Export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import a previously exported file to restore data. After importing, restart ComfyUI to apply changes.",
exportData: "Export",
importData: "Import",
exportSuccess: "Export complete",
exportError: "Export failed",
importSuccess: "Import complete",
importError: "Import failed",
saveSettings: "Save Settings",
settingsSaved: "Settings saved",
ollamaSaved: "Ollama settings saved",
@@ -444,6 +598,37 @@ const LANGUAGES = {
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
// -- Metadata tab --
tabMetadata: "Metadata",
metaSectionCkpt: "Checkpoint",
metaSectionVae: "VAE",
metaSectionDiff: "Diffusion Model",
metaSectionTe: "Text Encoder",
metaSectionLora: "LoRA",
metaSectionPrompt: "Prompt",
metaDropMain: "Drop image / JSON",
metaDropSub: "PNG · WebP · JSON",
metaParsing: "Parsing...",
metaFileTooLarge: "File too large (max 50 MB)",
metaNoMetadata: "No metadata found",
metaParseError: "Failed to parse metadata",
metaPromptPositive: "Positive",
metaPromptNegative: "Negative",
metaPromptText: "Text",
metaSelectPrompt: "Select a prompt above to view full text...",
metaFormatNoteTitle: "Supported formats",
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG (parameters)",
metaFmtFooocus: "Fooocus — PNG (fooocus_scheme)",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image subgraph workflows supported",
helpMetadataTitle: "Metadata Tab",
helpMetadataDesc: "Drop a ComfyUI-generated PNG/WebP or workflow JSON to instantly extract and view the embedded model and prompt information.",
helpMetadata1: "Left column: drop zone (image preview for PNG/WebP) and file info; click or drop to load",
helpMetadata2: "Center column: Checkpoint, VAE, Diffusion Model, and Text Encoder lists extracted from the workflow",
helpMetadata3: "Right column: LoRA list (with strength values) and Prompt list (click to view full text below)",
helpMetadata4: "Prompt actions — Copy: copy to clipboard; GenUI:P/N: set GenerateUI positive/negative prompt; Prompt:P/N: set Prompt tab preset positive/negative",
helpMetadata5: "Supported: ComfyUI PNG/WebP/JSON (standard + Flux.2 / Qwen-Image / Z-Image subgraph workflows), SD WebUI, SD Forge, Fooocus.",
},
ja: {
@@ -462,6 +647,7 @@ const LANGUAGES = {
// -- Tabs --
tabWorkflow: "ワークフロー",
tabGenerate: "生成UI",
tabGallery: "ギャラリー",
tabSettings: "設定",
// -- Workflow toolbar --
@@ -473,6 +659,13 @@ const LANGUAGES = {
import: "インポート",
badgeColors: "バッジ色",
resetColors: "リセット",
badgeManage: "バッジ管理",
badgeAdd: "追加",
badgeDelete: "削除",
badgeNone: "バッジが未定義です。",
badgeNoneHint: "バッジが未定義です。⚙ バッジ管理から追加できます。",
badgeNewLabel: "バッジ名...",
badgeColorHint: "色を変更",
defaultView: "デフォルト表示",
viewThumbnail: "サムネイル",
viewCard: "カード",
@@ -602,6 +795,10 @@ const LANGUAGES = {
tabNodes: "ノード",
nodesAllNodes: "全ノード",
nodesNodeSets: "ノードセット",
nodesCategoryTab: "カテゴリ",
nodesPackageTab: "パッケージ",
nodesSelectCategory: "カテゴリを選択...",
nodesSelectPackage: "パッケージを選択...",
nodesSearchPlaceholder: "ノードを検索...",
nodesAllCategories: "全カテゴリ",
nodesAllPackages: "全パッケージ",
@@ -667,11 +864,18 @@ const LANGUAGES = {
modelsTags: "タグ",
modelsTagsPlaceholder: "タグ1, タグ2, ...",
modelsModelType: "モデルタイプ",
modelsBadges: "バッジ",
modelsInfo: "情報",
modelsFavorite: "お気に入り",
modelsMemo: "メモ",
modelsMemoPlaceholder: "このモデルについてのメモ...",
modelsSave: "保存",
modelsSaved: "メタデータを保存しました",
modelsGenUIBtn: "GenUI Model",
modelsGenUITitle: "GenerateUIにこのモデルをセット",
modelsGenUIUnsupported: "このモデルタイプはGenerateUIに設定できません",
modelsGenUINoWorkflow: "GenerateUIにワークフローが読み込まれていません",
modelsGenUINoNode: (type) => `現在のワークフローに${type}ノードが見つかりません`,
modelsCopyName: "クリックでモデル名をコピー",
modelsCopiedName: "モデル名をコピーしました",
modelsFilePath: "ファイルパス",
@@ -679,6 +883,7 @@ const LANGUAGES = {
modelsCopiedPath: "ファイルパスをコピーしました",
modelsNoPreview: "プレビューなし",
modelsTagsHint: "(カンマ区切り)",
modelsAllDirs: "すべてのフォルダ",
modelsAllGroups: "すべてのグループ",
modelsCurrentGroups: "所属グループ",
modelsNoGroup: "グループに未所属",
@@ -695,6 +900,28 @@ const LANGUAGES = {
modelsGroupExists: "同名のグループが既に存在します",
modelsRenamePrompt: "新しいグループ名を入力:",
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
modelEnable: "有効化",
modelDisable: "無効化",
modelDisabled: "無効",
modelStatusAll: "すべて",
modelStatusEnabled: "有効のみ",
modelStatusDisabled: "無効のみ",
modelGroupEnableAll: "全て有効化",
modelGroupDisableAll: "全て無効化",
modelToggleError: "切り替えに失敗しました",
modelStatusWarning: "ComfyUIをリフレッシュ後に反映されます",
modelSelectMode: "選択",
modelSelectExit: "選択終了",
modelSelected: "件選択中",
modelBulkAddGroup: "グループに追加",
modelBulkRemoveGroup: "グループから削除",
modelBulkCreateAdd: "作成して追加",
modelBulkAddDone: "件をグループに追加しました",
modelBulkRemoveDone: "件をグループから削除しました",
modelBulkDelete: "ファイルを削除",
modelBulkDeleteConfirm: "{count}件のモデルファイルとプレビュー画像を完全に削除しますか?この操作は取り消せません。",
modelBulkDeleteDone: "件のモデルファイルを削除しました",
modelBulkDeleteError: "一部のモデルの削除に失敗しました",
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
civitaiFetch: "CivitAIから取得",
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
@@ -712,6 +939,63 @@ const LANGUAGES = {
modelsSideInfo: "基本情報",
modelsSideGroup: "グループ",
modelsSideCivitai: "CivitAI",
gallerySideMetadata: "メタデータ",
// -- Gallery --
galleryAllTags: "すべてのタグ",
galleryFolders: "フォルダ",
gallerySelectFolder: "フォルダを選択して画像を表示します。",
galleryNoImages: "画像が見つかりません。",
gallerySearchPlaceholder: "画像を検索...",
galleryOutputDir: "ギャラリー Outputフォルダ",
galleryOutputDirLabel: "Outputフォルダのパス",
galleryOutputDirHint: "ギャラリータブで参照するComfyUIのoutputフォルダを設定します。空欄の場合はComfyUIのデフォルトoutputディレクトリを使用します。",
tabAi: "AI TOOL",
// -- AI Tab --
aiSubTabTranslate: "翻訳",
aiSubTabVlm: "VLM",
aiSubTabSettings: "設定",
aiLangJa: "日本語", aiLangEn: "英語", aiLangZh: "中国語", aiLangFree: "Free",
aiTransInputPlaceholder: "翻訳するテキストを入力...",
aiTransOutputPlaceholder: "翻訳結果がここに表示されます...",
aiTranslateBtn: "翻訳",
aiCopyBtn: "コピー",
aiVlmDropLabel: "PNG / JPG / WebP をドロップまたはクリック",
aiVlmDescribe: "画像の解説",
aiVlmPromptCreate: "プロンプト作成",
aiVlmRunBtn: "実行",
aiVlmResultPlaceholder: "実行結果がここに表示されます...",
aiSettingsBackend: "バックエンド",
aiSettingsConnection: "接続設定",
aiSettingsTestBtn: "接続テスト",
aiSettingsModelSection: "モデル選択",
aiSettingsRefreshBtn: "更新",
aiSettingsModelPlaceholder: "-- モデルを選択 --",
aiSettingsFreeLang: "Free 言語設定",
aiSettingsInputLang: "入力言語",
aiSettingsOutputLang: "翻訳言語",
aiSettingsSaveBtn: "設定を保存",
aiToastNoText: "テキストを入力してください",
aiToastNoModel: "設定タブでモデルを選択してください",
aiToastInvalidUrl: "URLは http:// または https:// で始まる必要があります",
aiToastInvalidUrlInput: "http:// または https:// で始まるURLを入力してください",
aiStatusTranslating: "翻訳中...",
aiStatusRunning: "実行中...",
aiStatusDone: "完了",
aiStatusConnecting: "接続中...",
aiStatusConnectOk: "接続成功",
aiStatusConnectFail: "接続失敗: ",
aiToastTransFailed: "翻訳に失敗しました: ",
aiToastNoCopyText: "コピーするテキストがありません",
aiToastCopied: "コピーしました",
aiToastNoModels: "モデルが見つかりません",
aiToastModelsFailed: "モデル取得失敗: ",
aiToastSettingsSaved: "設定を保存しました",
aiToastNoImage: "画像をドロップしてください",
aiToastVlmFailed: "VLM実行失敗: ",
aiModels: "モデル",
// -- Help & Support --
tabHelp: "ヘルプ",
@@ -725,16 +1009,30 @@ const LANGUAGES = {
helpWf3: "グループの作成・名前変更・削除、ワークフローの割り当て・解除",
helpWf4: "JSON/PNGファイルのインポート(ボタンまたはドラッグ&ドロップ)",
helpWf5: "サイドパネル:サムネイル、JSONビューア(フォーマットバッジ:UI/API/App形式)、クリップボードコピー、グループ管理",
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、分析、カテゴリオーバーライド、タグ、メモ、AI要約、ComfyUIで開く、生成UIに読み込み、デフォルト設定、削除",
helpWf7: "カテゴリごとのバッジ色カスタマイズ",
helpWf8: "全ワークフローの入出力一括再分析(P:プロンプト入力、I:画像入力、出力:saveノード)",
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、バッジ付与、タグ、メモ、AI要約、ComfyUIで開く、生成UIに読み込み、デフォルト設定、削除",
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
helpGen2: "Workflowタブからワークフローを読み込み(App形式 .app.json は非対応)",
helpGen3: "パラメータ編集:プロンプト、画像(ドラッグ&ドロップ)、モデル(checkpoint/VAE/LoRA/ControlNet、フィルタ検索)、設定(Steps/CFG/サンプラー/スケジューラ/解像度)、Raw JSON",
helpGen4: "シード管理:ランダム / 固定 / 増加 / 減少",
helpGen5: "画像生成、進捗表示、生成停止",
helpGen6: "メイン画像とサムネイル履歴で結果表示",
helpGen7: "Eagle連携:生成画像をEagleへ自動保存",
helpGen3: "4タブ構成 — Input(プロンプト+画像)、Model、Settings、Feeder — Input/Model/SettingsタブにはRaw JSON列を常時表示",
helpGen4: "Inputタブ:左列にPrompt(上段)+ Image ドラッグ&ドロップ(下段)",
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA/ControlNet/UNET/TextEncoderのセレクタ(フィルタ検索付き)",
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映",
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
helpGen9: "画像生成、進捗表示、生成停止、結果表示",
helpGen10: "Eagle連携:生成画像をEagleへ自動保存",
helpGen11: "Checkpoint Batch:ドロップダウンのフォルダツリーからチェックポイントを選択 — フォルダにチェックでそのフォルダ内を一括選択、▶で展開して個別選択も可能;Filterで絞り込み;Pause/Resumeでモデル間の処理を一時停止・再開;Stopで現在の生成完了後に中断",
helpFeederTitle: "GenerateUI Tab — Feeder サブタブ",
helpFeederDesc: "comfyui-image-feeder カスタムノードが必要です。ImageFeeder ノードを操作し、user/default/image-loop-data/ に保存された画像ライブラリを使って連続生成ループを実行します。",
helpFeeder1: "左ペイン — ノード設定:ドロップダウンで対象 ImageFeeder ノードを選択;Directory・Sort Mode・Index・Start/End Index・Batch Size・Seed・Use Selection の各パラメータを編集;Apply で読み込み済みワークフローに反映",
helpFeeder2: "左ペイン — プリセット:現在のフォルダ+選択ファイルを名前付きプリセットとして保存;保存済みプリセットの読み込み・削除",
helpFeeder3: "左ペイン — Run:After gen モードで各生成後の index 動作を設定 — Loop(index を 0 に折り返して無限継続)、Increment(index を進め全画像消化で自動停止)、Fixed(常に同じ index を使用);Run ボタンでループ開始、Stop で中断",
helpFeeder4: "中央ペイン — フォルダツリー:image-loop-data 内のサブフォルダを階層表示;クリックで画像を読み込み;↺ ボタンでツリー再読み込み",
helpFeeder5: "中央ペイン — 画像グリッド:選択フォルダ内の画像をサムネイル表示;チェックボックスで選択に追加;All/None ボタンで一括選択・解除;クリックでプレビューパネルに表示",
helpFeeder6: "中央ペイン — プレビューパネル:クリックした画像を拡大表示し、ファイル名・解像度・ファイルサイズを確認",
helpFeeder7: "ステータスバー:現在のフォルダパスと、そのフォルダ内の総枚数に対する選択枚数を表示",
helpFeeder8: "Run 時のシード:右ペインのシード設定(ランダム / 固定 / 増加 / 減少)を使用 — ImageFeeder 自身の Seed フィールドはランダムソートの並び順のみに影響",
helpFeeder9: "Index 同期:生成完了後にノードが次の index を WebSocket で返送;Index フィールドが自動更新されて表示と実行状態が一致",
helpPrompt1: "AIアシスタント(Ollama):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応)",
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
@@ -742,6 +1040,8 @@ const LANGUAGES = {
helpPrompt5: "プリセットマネージャー:全プリセット一覧、お気に入り、グループ別フィルタリング",
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
helpPrompt8: "ワイルドカードツールバー:{|}・{n$|}・__|__・<lora:>などの構文をワンクリック挿入。選択テキストのラップも対応",
helpPrompt9: "ワイルドカードファイルマネージャー:.txt/.yamlファイルの作成・編集・削除。ファイル名をクリックすると__ファイル名__をカーソル位置に挿入",
helpSettings1: "UI言語と要約言語の設定",
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
@@ -749,24 +1049,42 @@ const LANGUAGES = {
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
helpSettings6: "デフォルトワークフローの設定・解除",
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
helpSettings9: "各セクションはアコーディオン式で折りたたみ可能",
helpSettings10: "Wildcard Integration:WFSのワイルドカードディレクトリをComfyUI-Impact-Packのwildcardsディレクトリへリンク(Windowsはジャンクション、他OSはシンボリックリンク)。既存ファイルは自動で移行されます",
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
helpNodes4: "ノードグループの作成・名前変更・削除、ノードの割り当て・解除",
helpNodes5: "サイドパネル:詳細(クラス名、パッケージ、カテゴリ、説明、フラグ)、I/O仕様(入出力の型・デフォルト値)、グループ管理",
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoderをサブタブで切り替え",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embeddingをサブタブで切り替え",
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ、ステータスフィルタ(すべて / 有効のみ / 無効のみ)",
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得;プレビュー画像がない場合は自動でダウンロード・保存",
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpModels8: "GenUI Modelボタン:選択モデルをGenerateUIの現在のワークフローの該当ノードに直接適用(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "ユーザー定義バッジ:自由ラベルのバッジをモデルに付与(Workflowタブと色パレットを共有)",
helpModels10: "モデルの有効/無効切り替え:拡張子リネーム(.disabledサフィックス)でComfyUIへの表示を制御。各カードの⏸ボタンで単体操作、グループパネルの「全て有効化 / 全て無効化」でグループ一括操作。変更はComfyUIリフレッシュ後に反映。",
helpModels11: "複数選択と一括操作:「選択」ボタンで選択モードに入り複数モデルにチェックを入れると、一括操作バーからグループへの追加/削除、またはファイルの完全削除(モデルファイル+プレビュー画像+.json/.infoなどのサイドカーファイル)が行えます。",
helpGalleryTitle: "Galleryタブ",
helpGallery1: "ComfyUIの出力フォルダ内画像を閲覧 — Settings > Gallery Output Folder でフォルダパスを設定可能",
helpGallery2: "3カラムレイアウト: フォルダツリー (左) | 画像グリッド (中央) | 詳細パネル (右)",
helpGallery3: "サムネイル・テーブル表示モード切替;ダブルクリックで全画面ライトボックス表示",
helpGallery4: "ツールバーフィルタ:お気に入り(★)、テキスト検索、タグフィルタ、グループフィルタ、日付・名前ソート",
helpGallery5: "フォルダツリー:クリックで画像を読み込み;▶で展開;「+ New」で選択フォルダ内にサブフォルダを作成;「Del」でそのフォルダとすべての内容を削除",
helpGallery6: "Ctrl+クリックで複数選択 — 一括操作:グループへ追加、一括お気に入り、一括お気に入り解除、移動(Move To...)、削除",
helpGallery7: "詳細パネル — Infoタブ:ファイル名、サイズ、日付、タグ(追加/削除)、メモ;「Move To...」で他フォルダへ移動;「Delete」で完全削除",
helpGallery8: "詳細パネル — Metadataタブ:PNGの埋め込みデータ(prompt / workflow キー)またはGenerateUIタブで保存したメタデータからワークフローJSONを表示;ワンクリックでクリップボードコピー",
helpGallery9: "詳細パネル — Groupsタブ:画像ごとのグループ作成、割り当て、解除、名前変更、削除",
helpGallery10: "ワークフロー自動保存:GenerateUIタブで生成した画像はワークフローが自動でギャラリーメタデータに保存され、Metadataタブで確認可能",
helpGallery11: "PNG (tEXt/iTXt)、JPEG、WebP、GIF対応;PNG形式はComfyUIの埋め込みJSON(prompt / workflow キー)を読み込んでワークフロー抽出が可能",
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
helpSidepanel2: "Workflowsタブ:お気に入りワークフロー閲覧、モデル種別フィルタ、グループフィルタ",
helpSidepanel3: "Nodesタブ:お気に入りノード、ノードセット、ノードグループの閲覧",
helpSidepanel2: "Workflowsタブ:All / ★ Favorites / Groupsサブタブ、Allビューでお気に入りに★表示",
helpSidepanel3: "Nodesタブ:All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Packageサブタブ;CategoryとPackageはドロップダウンでカテゴリ・カスタムノードパッケージ別に絞り込み可能",
helpSidepanel4: "Promptsタブ:プロンプトプリセットの閲覧(All/お気に入り/カテゴリ サブタブ)",
helpSidepanel5: "ワークフローをキャンバスにドラッグ&ドロップで読み込み",
helpSidepanel6: "ノード/ノードセットをキャンバスにドラッグ&ドロップで配置",
@@ -774,6 +1092,19 @@ const LANGUAGES = {
helpSidepanel8: "サイドバーアイテムからポジティブ(P)/ネガティブ(N)プロンプトを個別コピー",
helpSidepanel9: "ダブルクリックでもアイテムの読み込み・配置が可能",
helpSidepanel10: "各サブタブ内で検索してアイテムをすばやく検索",
helpSidepanel11: "Modelsタブ:All / ★ Favorites / Groups / By Typeサブタブ",
helpSidepanel12: "⚙ テーマボタン(ヘッダー):パネル背景・サブヘッダー背景・文字色・ボーダー・補助テキスト色をカスタマイズ可能、設定はlocalStorageに保存され次回起動時も反映",
helpSidepanel13: "I タブ(Information):ComfyUI の PNG / WebP / JSON をドロップするとサイドパネル内でモデル・LoRA・プロンプトのメタデータを確認可能 — サブタブ: model / lora / Prompts;UnetLoaderGGUF・QuadrupleCLIPLoader ノードタイプに対応",
helpSidepanel14: "Information タブ — キャンバスへドラッグ:model サブタブのアイテムをドラッグすると対応ローダーノードを配置(Checkpoint → CheckpointLoaderSimple、VAE → VAELoader、Diffusion Model → UNETLoader、Text Encoder → CLIPLoader);ダブルクリックでもキャンバス中央に配置可能",
helpSidepanel15: "Information タブ — lora サブタブ:個別 LoRA をドラッグで LoraLoader を配置;下部の「Multiple LORA」セクションをドラッグすると全 LoRA を一括で Lora Loader (LoraManager) に配置;Prompts サブタブ:プロンプトをドラッグするとテキスト入力済みの CLIPTextEncode ノードを配置;クリックで全文表示・コピー可能",
helpSidepanel16: "A タブ(AI ツール):翻訳・VLM サブタブ;設定(バックエンド・URL・モデル)は SPA の AI タブと localStorage で共有される",
helpAiTitle: "AI タブ(A)",
helpAi1: "翻訳サブタブ:Ollama または LM Studio を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳",
helpAi2: "入力言語・出力言語セレクターと ⇄ 入替ボタン付き;テキスト内容も同時に入替;選択は自動保存",
helpAi3: "TOOLS ペイン:画像をドロップゾーンにドロップしてタスクを選択(画像の解説 / プロンプト作成 / タグ作成)し、実行ボタンでビジョンモデルに解析させる",
helpAi4: "VLM の結果は出力エリアに表示され、コピーボタンでクリップボードに取得可能",
helpAi5: "設定サブタブ:バックエンド(Ollama / LM Studio)・API URL・接続テスト・モデル選択・翻訳の Free 言語名を設定",
helpAi6: "設定は localStorage に保存され、Library パネルの A タブと共有される",
helpShortcutsTitle: "キーボード操作",
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
@@ -809,7 +1140,7 @@ const LANGUAGES = {
comfyuiUrlHint: "ComfyUIプラグインとして動作する場合は空欄(同一オリジン推奨)。別サーバーの場合のみ設定してください。",
connectedCheck: "接続成功 \u2713",
failedConnect: "接続失敗 \u2717",
ollamaSettings: "Ollama 設定",
ollamaSettings: "Ollama 設定(プロンプトタブ)",
ollamaUrl: "Ollama API URL",
ollamaDefaultModel: "デフォルトモデル",
selectModel: "モデルを選択...",
@@ -827,6 +1158,14 @@ const LANGUAGES = {
eagleIntegration: "Eagle 連携 (オプション)",
eagleUrl: "Eagle URL",
eagleAutoSave: "生成画像をEagleに自動保存",
dataManagement: "データ管理",
dataManagementHint: "設定・メタデータ・プロンプトなど全データをJSONファイルにエクスポート、またはインポートして復元できます。インポート後はComfyUIを再起動してください。",
exportData: "エクスポート",
importData: "インポート",
exportSuccess: "エクスポート完了",
exportError: "エクスポート失敗",
importSuccess: "インポート完了",
importError: "インポート失敗",
saveSettings: "設定を保存",
settingsSaved: "設定を保存しました",
ollamaSaved: "Ollama設定を保存しました",
@@ -886,6 +1225,37 @@ const LANGUAGES = {
themeFontTrainOne: "トレイン One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
// -- Metadata tab --
tabMetadata: "メタデータ",
metaSectionCkpt: "Checkpoint",
metaSectionVae: "VAE",
metaSectionDiff: "Diffusion Model",
metaSectionTe: "Text Encoder",
metaSectionLora: "LoRA",
metaSectionPrompt: "プロンプト",
metaDropMain: "画像 / JSON をドロップ",
metaDropSub: "PNG · WebP · JSON",
metaParsing: "解析中...",
metaFileTooLarge: "ファイルが大きすぎます(最大 50 MB)",
metaNoMetadata: "メタデータが見つかりません",
metaParseError: "メタデータの解析に失敗しました",
metaPromptPositive: "ポジティブ",
metaPromptNegative: "ネガティブ",
metaPromptText: "テキスト",
metaSelectPrompt: "上のプロンプトを選択すると全文を表示します...",
metaFormatNoteTitle: "対応フォーマット",
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters チャンク)",
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme チャンク)",
metaFormatTodo: "Flux.2 / Qwen-Image / Z-Image サブグラフ形式に対応済み",
helpMetadataTitle: "Metadata タブ",
helpMetadataDesc: "ComfyUI で生成した PNG / WebP または ワークフロー JSON をドロップすると、埋め込まれたモデル・プロンプト情報を即座に抽出・表示します。",
helpMetadata1: "左列: ドロップゾーン(PNG/WebP は画像プレビュー表示)+ファイル情報・対応フォーマット説明",
helpMetadata2: "中列: ワークフローから抽出した Checkpoint・VAE・Diffusion Model・Text Encoder の一覧",
helpMetadata3: "右列: LoRA 一覧(強度値付き)+プロンプト一覧(クリックで下部に全文表示)",
helpMetadata4: "プロンプトアクション — Copy: クリップボードにコピー; GenUI:P/N: GenerateUI のポジティブ/ネガティブプロンプトにセット; Prompt:P/N: Prompt タブ Presets のポジティブ/ネガティブにセット",
helpMetadata5: "対応フォーマット: ComfyUI PNG/WebP/JSON(標準形式 + Flux.2 / Qwen-Image / Z-Image サブグラフ形式)・SD WebUI・SD Forge・Fooocus。",
},
zh: {
@@ -904,6 +1274,7 @@ const LANGUAGES = {
// -- Tabs --
tabWorkflow: "工作流",
tabGenerate: "生成UI",
tabGallery: "图库",
tabSettings: "设置",
// -- Workflow toolbar --
@@ -915,6 +1286,13 @@ const LANGUAGES = {
import: "导入",
badgeColors: "标签颜色",
resetColors: "重置",
badgeManage: "管理标签",
badgeAdd: "添加",
badgeDelete: "删除",
badgeNone: "尚未定义标签。",
badgeNoneHint: "尚未定义标签。点击 ⚙ 管理标签来添加。",
badgeNewLabel: "标签名称...",
badgeColorHint: "更改颜色",
defaultView: "默认视图",
viewThumbnail: "缩略图",
viewCard: "卡片",
@@ -1040,6 +1418,10 @@ const LANGUAGES = {
tabNodes: "节点",
nodesAllNodes: "所有节点",
nodesNodeSets: "节点集",
nodesCategoryTab: "分类",
nodesPackageTab: "包",
nodesSelectCategory: "选择分类...",
nodesSelectPackage: "选择包...",
nodesSearchPlaceholder: "搜索节点...",
nodesAllCategories: "所有分类",
nodesAllPackages: "所有包",
@@ -1105,11 +1487,18 @@ const LANGUAGES = {
modelsTags: "标签",
modelsTagsPlaceholder: "标签1, 标签2, ...",
modelsModelType: "模型类型",
modelsBadges: "标签",
modelsInfo: "信息",
modelsFavorite: "收藏",
modelsMemo: "备注",
modelsMemoPlaceholder: "关于此模型的备注...",
modelsSave: "保存",
modelsSaved: "元数据已保存",
modelsGenUIBtn: "GenUI Model",
modelsGenUITitle: "在GenerateUI中设置此模型",
modelsGenUIUnsupported: "此模型类型无法在GenerateUI中设置",
modelsGenUINoWorkflow: "GenerateUI中未加载工作流",
modelsGenUINoNode: (type) => `当前工作流中未找到${type}节点`,
modelsCopyName: "点击复制模型名称",
modelsCopiedName: "模型名称已复制",
modelsFilePath: "文件路径",
@@ -1117,6 +1506,7 @@ const LANGUAGES = {
modelsCopiedPath: "文件路径已复制",
modelsNoPreview: "无预览",
modelsTagsHint: "(逗号分隔)",
modelsAllDirs: "所有文件夹",
modelsAllGroups: "所有分组",
modelsCurrentGroups: "当前分组",
modelsNoGroup: "未分配到任何分组",
@@ -1133,6 +1523,28 @@ const LANGUAGES = {
modelsGroupExists: "同名分组已存在",
modelsRenamePrompt: "输入新分组名称:",
modelsDeleteGroupConfirm: "删除分组「{name}」?",
modelEnable: "启用",
modelDisable: "禁用",
modelDisabled: "已禁用",
modelStatusAll: "全部",
modelStatusEnabled: "仅启用",
modelStatusDisabled: "仅禁用",
modelGroupEnableAll: "全部启用",
modelGroupDisableAll: "全部禁用",
modelToggleError: "切换失败",
modelStatusWarning: "刷新ComfyUI后生效",
modelSelectMode: "选择",
modelSelectExit: "退出选择",
modelSelected: "个已选中",
modelBulkAddGroup: "添加到分组",
modelBulkRemoveGroup: "从分组移除",
modelBulkCreateAdd: "创建并添加",
modelBulkAddDone: "个模型已添加到分组",
modelBulkRemoveDone: "个模型已从分组移除",
modelBulkDelete: "删除文件",
modelBulkDeleteConfirm: "永久删除 {count} 个模型文件及其预览图?此操作无法撤销。",
modelBulkDeleteDone: "个模型文件已删除",
modelBulkDeleteError: "部分模型删除失败",
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
civitaiFetch: "从CivitAI获取",
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
@@ -1150,6 +1562,63 @@ const LANGUAGES = {
modelsSideInfo: "基本信息",
modelsSideGroup: "分组",
modelsSideCivitai: "CivitAI",
gallerySideMetadata: "元数据",
// -- Gallery --
galleryAllTags: "所有标签",
galleryFolders: "文件夹",
gallerySelectFolder: "选择文件夹以浏览图片。",
galleryNoImages: "未找到图片。",
gallerySearchPlaceholder: "搜索图片...",
galleryOutputDir: "图库输出文件夹",
galleryOutputDirLabel: "输出文件夹路径",
galleryOutputDirHint: "设置图库标签页浏览的ComfyUI输出文件夹。留空则使用ComfyUI默认输出目录。",
tabAi: "AI TOOL",
// -- AI Tab --
aiSubTabTranslate: "翻译",
aiSubTabVlm: "VLM",
aiSubTabSettings: "设置",
aiLangJa: "日语", aiLangEn: "英语", aiLangZh: "中文", aiLangFree: "Free",
aiTransInputPlaceholder: "输入要翻译的文字...",
aiTransOutputPlaceholder: "翻译结果将显示在这里...",
aiTranslateBtn: "翻译",
aiCopyBtn: "复制",
aiVlmDropLabel: "拖放 PNG / JPG / WebP 或点击",
aiVlmDescribe: "图像说明",
aiVlmPromptCreate: "生成提示词",
aiVlmRunBtn: "执行",
aiVlmResultPlaceholder: "执行结果将显示在这里...",
aiSettingsBackend: "后端",
aiSettingsConnection: "连接设置",
aiSettingsTestBtn: "连接测试",
aiSettingsModelSection: "选择模型",
aiSettingsRefreshBtn: "刷新",
aiSettingsModelPlaceholder: "-- 请选择模型 --",
aiSettingsFreeLang: "Free 语言设置",
aiSettingsInputLang: "输入语言",
aiSettingsOutputLang: "翻译语言",
aiSettingsSaveBtn: "保存设置",
aiToastNoText: "请输入文字",
aiToastNoModel: "请在设置标签中选择模型",
aiToastInvalidUrl: "URL必须以 http:// 或 https:// 开头",
aiToastInvalidUrlInput: "请输入以 http:// 或 https:// 开头的URL",
aiStatusTranslating: "翻译中...",
aiStatusRunning: "执行中...",
aiStatusDone: "完成",
aiStatusConnecting: "连接中...",
aiStatusConnectOk: "连接成功",
aiStatusConnectFail: "连接失败:",
aiToastTransFailed: "翻译失败:",
aiToastNoCopyText: "没有可复制的文字",
aiToastCopied: "已复制",
aiToastNoModels: "未找到模型",
aiToastModelsFailed: "获取模型失败:",
aiToastSettingsSaved: "设置已保存",
aiToastNoImage: "请拖放图像",
aiToastVlmFailed: "VLM执行失败:",
aiModels: "个模型",
// -- Help & Support --
tabHelp: "帮助",
@@ -1163,16 +1632,30 @@ const LANGUAGES = {
helpWf3: "创建、重命名、删除分组,分配/移除工作流",
helpWf4: "通过按钮或拖放导入JSON/PNG文件",
helpWf5: "侧面板:缩略图、JSON查看器(格式徽章:UI/API/App)、剪贴板复制、分组管理",
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、分析、分类覆盖、标签、备注、AI摘要、在ComfyUI中打开、加载到生成UI、设为默认、删除",
helpWf7: "按分类自定义徽章颜色",
helpWf8: "批量重新分析所有工作流I/O(P:提示词节点、I:图片节点、输出:保存节点)",
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、徽章分配、标签、备注、AI摘要、在ComfyUI中打开、加载到生成UI、设为默认、删除",
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
helpGen1: "连接/断开ComfyUI,检查连接状态",
helpGen2: "从工作流标签页加载工作流(不支持App格式.app.json)",
helpGen3: "编辑参数:提示词、图片(拖放)、模型(checkpoint/VAE/LoRA/ControlNet含筛选)、设置(Steps/CFG/采样器/调度器/分辨率)、Raw JSON",
helpGen4: "种子管理:随机/固定/递增/递减",
helpGen5: "生成图片、进度显示、停止生成",
helpGen6: "主图和缩略图历史查看结果",
helpGen7: "Eagle集成:自动保存生成图片到Eagle",
helpGen3: "4标签布局 — Input(提示词+图片)、Model、Settings、Feeder — Input/Model/Settings标签各含Raw JSON列实时预览",
helpGen4: "Input标签:左列为Prompt(上)+ Image拖放(下)",
helpGen5: "Model标签:Checkpoint/VAE/LoRA/ControlNet/UNET/TextEncoder选择器(含筛选)",
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流",
helpGen8: "种子管理:随机/固定/递增/递减",
helpGen9: "生成图片、进度显示、停止生成、查看结果",
helpGen10: "Eagle集成:自动保存生成图片到Eagle",
helpGen11: "Checkpoint Batch:从下拉框文件夹树中选择检查点 — 勾选文件夹可一键选中该文件夹内的所有文件,▶展开后也可单独选择;使用Filter筛选;Pause/Resume可在模型之间暂停和继续;Stop在当前生成完成后中止",
helpFeederTitle: "GenerateUI Tab — Feeder 子标签",
helpFeederDesc: "需要 comfyui-image-feeder 自定义节点。用于操控 ImageFeeder 节点,并对存储在 user/default/image-loop-data/ 中的图片库执行连续生成循环。",
helpFeeder1: "左侧面板 — 节点设置:从下拉框选择目标 ImageFeeder 节点;编辑所有节点参数(Directory、Sort Mode、Index、Start/End Index、Batch Size、Seed、Use Selection);点击 Apply 将更改写入当前工作流",
helpFeeder2: "左侧面板 — 预设:将当前目录+选择文件保存为命名预设;加载或删除已保存的预设",
helpFeeder3: "左侧面板 — Run:After gen 模式决定每次生成后的 index 行为 — Loop(回到0并无限循环)、Increment(递增 index,全部图片用完后自动停止)、Fixed(始终使用相同 index);点击 Run 开始循环,Stop 中断",
helpFeeder4: "中央面板 — 文件夹树:浏览 image-loop-data 内的子文件夹;点击文件夹加载图片;点击 ↺ 重新加载目录树",
helpFeeder5: "中央面板 — 图片网格:缩略图显示所选文件夹内的所有图片;勾选图片加入选择集;All/None 按钮一键全选或清除;点击图片打开预览面板",
helpFeeder6: "中央面板 — 预览面板:放大显示点击的图片,并显示文件名、分辨率和文件大小",
helpFeeder7: "状态栏:显示当前文件夹路径,以及该文件夹内已选中图片数占总数的比例",
helpFeeder8: "Run 时的种子:使用右侧面板的种子设置(随机/固定/递增/递减)— ImageFeeder 自身的 Seed 字段仅影响随机排序的顺序",
helpFeeder9: "Index 同步:每次生成完成后节点通过 WebSocket 返回下一个 index;Index 字段自动更新,保持显示与执行状态一致",
helpPrompt1: "AI助手(Ollama):模型选择、聊天咨询提示词、图片附件(视觉模型)",
helpPrompt2: "将AI回复应用到生成UI的提示词",
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
@@ -1180,6 +1663,8 @@ const LANGUAGES = {
helpPrompt5: "预设管理器:浏览所有预设、收藏、按组筛选",
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
helpPrompt8: "通配符工具栏:一键插入通配符语法({|}、{n$|}、__|__、<lora:>等);支持包裹选中文本",
helpPrompt9: "通配符文件管理:创建/编辑/删除.txt/.yaml通配符文件;点击文件名在光标处插入__文件名__",
helpSettings1: "UI语言和摘要语言设置",
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
helpSettings3: "工作流数据文件夹路径更改/重置",
@@ -1187,24 +1672,42 @@ const LANGUAGES = {
helpSettings5: "Ollama URL、默认模型、连接测试",
helpSettings6: "默认工作流设置/取消",
helpSettings7: "Eagle集成URL、自动保存开关",
helpSettings8: "各部分可折叠展开",
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
helpSettings9: "各部分可折叠展开",
helpSettings10: "Wildcard Integration:将WFS通配符目录链接到ComfyUI-Impact-Pack的wildcards目录(Windows为目录联结,其他OS为符号链接);现有文件自动迁移",
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
helpNodes2: "卡片和表格视图模式切换(带分页)",
helpNodes3: "收藏节点,添加自定义标签进行整理",
helpNodes4: "创建、重命名、删除节点组,分配/移除节点",
helpNodes5: "侧面板:详情(类名、包、分类、描述、标志)、I/O规格(输入输出类型和默认值)、组管理",
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder,通过子标签切换",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embedding,通过子标签切换",
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选、状态筛选(全部 / 仅启用 / 仅禁用)",
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取;无预览图时自动下载并保存",
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpModels8: "GenUI Model按钮:将选中模型直接应用到GenerateUI当前工作流的对应节点(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "用户自定义徽章:为模型添加自由标签徽章(颜色调色板与Workflow标签共享)",
helpModels10: "模型启用/禁用:通过重命名文件扩展名(添加.disabled后缀)控制ComfyUI中的显示。点击每个卡片上的⏸按钮单独操作,或在分组面板中使用「全部启用/全部禁用」进行批量操作。更改在刷新ComfyUI后生效。",
helpModels11: "多选与批量操作:点击「选择」按钮进入选择模式,勾选多个模型后,可通过批量操作栏将其添加/移出分组,或永久删除文件(模型文件+预览图+.json/.info等附属文件)。",
helpGalleryTitle: "图库标签页 (Gallery)",
helpGallery1: "浏览 ComfyUI 输出文件夹中的图像 — 可以在 设置 > 图库输出文件夹 中设置文件夹路径",
helpGallery2: "三列布局:文件夹树(左) | 图像网格(中) | 详情面板(右)",
helpGallery3: "缩略图和表格视图模式;双击任何图像可在全屏灯箱中打开",
helpGallery4: "工具栏过滤器:收藏 (★)、文本搜索、标签过滤、分组过滤、按日期/名称排序",
helpGallery5: "文件夹树:点击加载图像,▶展开;'+ New'在选定文件夹内创建子文件夹;'Del'删除该文件夹及其所有内容",
helpGallery6: "使用Ctrl+点击多选 — 批量操作:添加到分组、全部收藏、取消全部收藏、移动到...、删除",
helpGallery7: "详情面板 — 信息(Info)标签:文件名、大小、日期、标签(添加/移除)、备注;'移动到...'将图像移至另一文件夹;'删除'永久删除",
helpGallery8: "详情面板 — 元数据(Metadata)标签:显示来自PNG嵌入数据(prompt / workflow键)或Generate UI标签保存的工作流JSON;一键复制到剪贴板",
helpGallery9: "详情面板 — 分组(Groups)标签:对每个图像进行组的创建、分配、移除、重命名和删除",
helpGallery10: "工作流自动保存:在Generate UI标签生成的图像,其工作流会自动保存到图库元数据,并在Metadata标签中显示",
helpGallery11: "支持PNG(tEXt/iTXt)、JPEG、WebP、GIF;PNG格式读取ComfyUI嵌入的JSON(prompt / workflow键)提取工作流",
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
helpSidepanel2: "Workflows标签:浏览收藏工作流、按模型类型筛选、按分组筛选",
helpSidepanel3: "Nodes标签:浏览收藏节点、节点集、节点组",
helpSidepanel2: "Workflows标签:All / ★ Favorites / Groups子标签,All视图中收藏项显示★",
helpSidepanel3: "Nodes标签:All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package子标签;Category和Package可通过下拉菜单按分类或自定义节点包筛选节点",
helpSidepanel4: "Prompts标签:浏览提示词预设(全部/收藏/分类 子标签)",
helpSidepanel5: "将工作流拖放到画布上即可加载",
helpSidepanel6: "将节点/节点集拖放到画布上即可放置",
@@ -1212,6 +1715,19 @@ const LANGUAGES = {
helpSidepanel8: "从侧边栏项目中单独复制正面(P)或负面(N)提示词",
helpSidepanel9: "双击项目也可加载/放置",
helpSidepanel10: "在各子标签内搜索快速查找项目",
helpSidepanel11: "Models标签:All / ★ Favorites / Groups / By Type子标签",
helpSidepanel12: "⚙ 主题按钮(标题栏):可自定义面板背景、子标题背景、文字颜色、边框及辅助文字颜色,设置保存至localStorage并在下次打开时生效",
helpSidepanel13: "I 标签(Information):拖放 ComfyUI PNG/WebP/JSON 即可在侧面板内查看模型、LoRA 和提示词元数据 — 子标签:model / lora / Prompts;支持 UnetLoaderGGUF 和 QuadrupleCLIPLoader 节点类型",
helpSidepanel14: "Information 标签 — 拖拽到画布:model 子标签拖拽可放置对应加载节点(Checkpoint → CheckpointLoaderSimple、VAE → VAELoader、Diffusion Model → UNETLoader、Text Encoder → CLIPLoader);双击也可放置到画布中央",
helpSidepanel15: "Information 标签 — lora 子标签:拖拽单个 LoRA 放置 LoraLoader;底部「Multiple LORA」区域可将全部 LoRA 一次性放入 Lora Loader (LoraManager) 节点;Prompts 子标签:拖拽提示词放置预填文本的 CLIPTextEncode 节点;点击可查看全文并复制",
helpSidepanel16: "A 标签(AI 工具):翻译子标签和 VLM 子标签;设置(后端、URL、模型)通过 localStorage 与 SPA 的 AI 标签共享",
helpAiTitle: "AI 标签页(A)",
helpAi1: "翻译子标签:使用 Ollama 或 LM Studio 在日语、英语、中文或自定义语言(Free)之间翻译文本",
helpAi2: "源语言和目标语言选择器,带 ⇄ 互换按钮;文本内容也同时互换;选择自动保存",
helpAi3: "TOOLS 面板:将图像拖放到投放区域,选择任务(图像描述 / 提示词生成 / 标签生成),点击执行按钮用视觉模型进行分析",
helpAi4: "VLM 结果显示在输出区域,可通过复制按钮复制到剪贴板",
helpAi5: "设置子标签:选择后端(Ollama / LM Studio)、设置 API URL、测试连接、选择模型、配置翻译的 Free 语言名称",
helpAi6: "设置保存至 localStorage,并与 Library 面板的 A 标签共享",
helpShortcutsTitle: "键盘操作",
helpShortcuts1: "Escape:关闭弹窗/面板",
helpShortcuts2: "Enter:确认重命名、发送聊天",
@@ -1247,7 +1763,7 @@ const LANGUAGES = {
comfyuiUrlHint: "作为ComfyUI插件运行时留空(推荐同源)。仅在不同服务器时设置。",
connectedCheck: "已连接 \u2713",
failedConnect: "连接失败 \u2717",
ollamaSettings: "Ollama 设置",
ollamaSettings: "Ollama 设置(提示词标签页)",
ollamaUrl: "Ollama API URL",
ollamaDefaultModel: "默认模型",
selectModel: "选择模型...",
@@ -1265,6 +1781,14 @@ const LANGUAGES = {
eagleIntegration: "Eagle 集成 (可选)",
eagleUrl: "Eagle URL",
eagleAutoSave: "自动保存生成图片到Eagle",
dataManagement: "数据管理",
dataManagementHint: "将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入之前导出的文件以恢复数据。导入后请重启ComfyUI以使更改生效。",
exportData: "导出",
importData: "导入",
exportSuccess: "导出完成",
exportError: "导出失败",
importSuccess: "导入完成",
importError: "导入失败",
saveSettings: "保存设置",
settingsSaved: "设置已保存",
ollamaSaved: "Ollama设置已保存",
@@ -1324,6 +1848,37 @@ const LANGUAGES = {
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
// -- Metadata tab --
tabMetadata: "元数据",
metaSectionCkpt: "Checkpoint",
metaSectionVae: "VAE",
metaSectionDiff: "扩散模型",
metaSectionTe: "文本编码器",
metaSectionLora: "LoRA",
metaSectionPrompt: "提示词",
metaDropMain: "拖放图片 / JSON",
metaDropSub: "PNG · WebP · JSON",
metaParsing: "解析中...",
metaFileTooLarge: "文件过大(最大 50 MB)",
metaNoMetadata: "未找到元数据",
metaParseError: "元数据解析失败",
metaPromptPositive: "正向提示",
metaPromptNegative: "负向提示",
metaPromptText: "文本",
metaSelectPrompt: "选择上方提示词以查看全文...",
metaFormatNoteTitle: "支持格式",
metaFmtComfyui: "ComfyUI — PNG / WebP / JSON",
metaFmtSdwebui: "SD WebUI / SD Forge — PNG(parameters)",
metaFmtFooocus: "Fooocus — PNG(fooocus_scheme)",
metaFormatTodo: "已支持 Flux.2 / Qwen-Image / Z-Image 子图工作流",
helpMetadataTitle: "Metadata 标签页",
helpMetadataDesc: "拖放 ComfyUI 生成的 PNG/WebP 或工作流 JSON,即可提取并显示其中的模型和提示词信息。",
helpMetadata1: "左列:拖放区(PNG/WebP 显示图片预览)及文件信息与支持格式说明",
helpMetadata2: "中列:从工作流中提取的 Checkpoint、VAE、扩散模型、文本编码器列表",
helpMetadata3: "右列:LoRA 列表(含强度值)和提示词列表(点击在下方显示全文)",
helpMetadata4: "提示词操作 — Copy:复制到剪贴板;GenUI:P/N:设置 GenerateUI 正/负向提示;Prompt:P/N:设置 Prompt 标签页预设正/负向提示",
helpMetadata5: "支持格式:ComfyUI PNG/WebP/JSON(标准格式 + Flux.2 / Qwen-Image / Z-Image 子图工作流)、SD WebUI、SD Forge、Fooocus。",
},
};
+752
View File
@@ -0,0 +1,752 @@
/**
* Metadata Tab
* Drop a PNG/WebP/JSON file to extract and display model/prompt metadata.
* Parsing logic adapted from model-and-prompt-from-metadata (workflow_utils.js).
*/
import { t } from "./i18n.js";
// ── File size limit ───────────────────────────────────────────
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// ── Sanitize JSON (NaN/Infinity → null) ──────────────────────
function sanitizeJSON(text) {
return text
.replace(/-Infinity\b/g, "null")
.replace(/\bInfinity\b/g, "null")
.replace(/\bNaN\b/g, "null");
}
// ── WebP EXIF ────────────────────────────────────────────────
async function readWebPEXIFChunk(file) {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const ascii = new TextDecoder("latin1");
if (bytes.byteLength < 12) return null;
if (ascii.decode(bytes.slice(0, 4)) !== "RIFF") return null;
if (ascii.decode(bytes.slice(8, 12)) !== "WEBP") return null;
let offset = 12;
while (offset + 8 <= buffer.byteLength) {
const fourcc = ascii.decode(bytes.slice(offset, offset + 4));
const chunkSize = view.getUint32(offset + 4, true);
if (fourcc === "EXIF") return bytes.slice(offset + 8, offset + 8 + chunkSize);
offset += 8 + chunkSize;
if (chunkSize % 2 === 1) offset++;
}
return null;
}
function extractWorkflowFromEXIF(exifBytes) {
const utf8 = new TextDecoder("utf-8", { fatal: false });
const text = utf8.decode(exifBytes);
for (const key of ["workflow:", "prompt:"]) {
const idx = text.indexOf(key + "{");
if (idx < 0) continue;
let jsonStr = text.slice(idx + key.length);
const nullIdx = jsonStr.indexOf("\x00");
if (nullIdx >= 0) jsonStr = jsonStr.slice(0, nullIdx);
try { return JSON.parse(sanitizeJSON(jsonStr)); } catch {
const lb = jsonStr.lastIndexOf("}");
if (lb > 0) { try { return JSON.parse(sanitizeJSON(jsonStr.slice(0, lb + 1))); } catch {} }
}
}
return null;
}
// ── PNG text chunks ───────────────────────────────────────────
function findNull(arr, start = 0) {
for (let i = start; i < arr.length; i++) if (arr[i] === 0) return i;
return -1;
}
function parseTEXtChunk(data, latin1) {
const np = findNull(data);
if (np === -1) return null;
return { keyword: latin1.decode(data.slice(0, np)), text: latin1.decode(data.slice(np + 1)) };
}
function parseITXtChunk(data, latin1, utf8) {
const np = findNull(data);
if (np === -1) return null;
const keyword = latin1.decode(data.slice(0, np));
let pos = np + 3;
pos = findNull(data, pos); if (pos === -1) return null; pos++;
pos = findNull(data, pos); if (pos === -1) return null; pos++;
return { keyword, text: utf8.decode(data.slice(pos)) };
}
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];
for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null;
const view = new DataView(buffer);
const latin1 = new TextDecoder("latin1");
const utf8 = new TextDecoder("utf-8");
let offset = 8;
const chunks = {};
while (offset + 12 <= buffer.byteLength) {
const length = view.getUint32(offset);
if (offset + 12 + length > buffer.byteLength) break;
const type = latin1.decode(bytes.slice(offset + 4, offset + 8));
const data = bytes.slice(offset + 8, offset + 8 + length);
if (type === "tEXt") { const c = parseTEXtChunk(data, latin1); if (c) chunks[c.keyword] = c.text; }
else if (type === "iTXt") { const c = parseITXtChunk(data, latin1, utf8); if (c) chunks[c.keyword] = c.text; }
offset += 12 + length;
}
return chunks;
}
// ── Workflow extraction helpers ───────────────────────────────
function collectUnique(arr) {
const seen = new Set(), out = [];
for (const v of arr) { if (v && typeof v === "string" && !seen.has(v)) { seen.add(v); out.push(v); } }
return out;
}
function collectAllNodes(workflow) {
if (!Array.isArray(workflow.nodes)) return [];
const all = [...workflow.nodes];
for (const sg of workflow.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) all.push(...sg.nodes);
return all;
}
const META_NODE_TYPES = new Set(["ImageMetadataCheckpointLoader", "ImageMetadataPromptLoader"]);
const VAE_NONE = "None";
function extractCheckpoints(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => n.type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
return collectUnique(Object.values(wf).filter(n => n?.class_type?.toLowerCase().includes("checkpoint") || META_NODE_TYPES.has(n?.class_type)).map(n => n.inputs?.ckpt_name));
}
function extractVAEs(wf) {
if (!wf || typeof wf !== "object") return [];
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).flatMap(n => { if (n.type === "VAELoader") return [n.widgets_values?.[0]]; if (META_NODE_TYPES.has(n.type ?? "")) { const v = n.widgets_values?.[1]; return v && v !== VAE_NONE ? [v] : []; } return []; }));
return collectUnique(Object.values(wf).flatMap(n => { if (!n || typeof n !== "object") return []; if (n.class_type === "VAELoader") return [n.inputs?.vae_name]; if (META_NODE_TYPES.has(n.class_type ?? "")) { const v = n.inputs?.vae_name; return v && v !== VAE_NONE ? [v] : []; } return []; }));
}
function extractDiffusionModels(wf) {
if (!wf || typeof wf !== "object") return [];
const UNET_TYPES = new Set(["UNETLoader", "UnetLoaderGGUF", "UNETLoaderGGUF"]);
if (Array.isArray(wf.nodes)) return collectUnique(collectAllNodes(wf).filter(n => UNET_TYPES.has(n.type)).map(n => n.widgets_values?.[0]));
return collectUnique(Object.values(wf).filter(n => UNET_TYPES.has(n?.class_type)).map(n => n.inputs?.unet_name));
}
function extractTextEncoders(wf) {
if (!wf || typeof wf !== "object") return [];
const names = [];
if (Array.isArray(wf.nodes)) {
for (const n of collectAllNodes(wf)) {
if (n.type === "CLIPLoader") { if (n.widgets_values?.[0]) names.push(n.widgets_values[0]); }
else if (n.type === "DualCLIPLoader") { [0,1].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
else if (n.type === "TripleCLIPLoader") { [0,1,2].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
else if (n.type === "QuadrupleCLIPLoader") { [0,1,2,3].forEach(i => { if (n.widgets_values?.[i]) names.push(n.widgets_values[i]); }); }
}
} else {
for (const n of Object.values(wf)) {
if (!n || typeof n !== "object") continue;
const ct = n.class_type ?? "";
if (ct === "CLIPLoader") { if (n.inputs?.clip_name) names.push(n.inputs.clip_name); }
else if (ct === "DualCLIPLoader") { if (n.inputs?.clip_name1) names.push(n.inputs.clip_name1); if (n.inputs?.clip_name2) names.push(n.inputs.clip_name2); }
else if (ct === "TripleCLIPLoader") { ["clip_name1","clip_name2","clip_name3"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
else if (ct === "QuadrupleCLIPLoader") { ["clip_name1","clip_name2","clip_name3","clip_name4"].forEach(k => { if (n.inputs?.[k]) names.push(n.inputs[k]); }); }
}
}
return collectUnique(names);
}
function extractLoRAs(wf) {
if (!wf || typeof wf !== "object") return [];
const results = [], seen = new Set();
function add(name, sm, sc) {
if (!name || typeof name !== "string" || name === "None" || seen.has(name)) return;
seen.add(name);
results.push({ name, strength_model: typeof sm === "number" ? sm : 1.0, strength_clip: typeof sc === "number" ? sc : 1.0 });
}
if (Array.isArray(wf.nodes)) {
for (const n of collectAllNodes(wf)) {
const type = n.type ?? "";
if (type === "LoraLoader") add(n.widgets_values?.[0], n.widgets_values?.[1], n.widgets_values?.[2]);
else if (type === "LoraLoaderModelOnly") add(n.widgets_values?.[0], n.widgets_values?.[1], 1.0);
else if (type === "ImageMetadataLoRALoader") { for (let i = 0; i < 3; i++) add(n.widgets_values?.[i*3], n.widgets_values?.[i*3+1], n.widgets_values?.[i*3+2]); }
else if (type === "Lora Loader (LoraManager)") { const list = n.widgets_values?.find(v => Array.isArray(v)); if (list) for (const l of list) { if (l?.active !== false) add(l?.name, l?.strength ?? 1.0, l?.clipStrength ?? l?.strength ?? 1.0); } }
}
} else {
for (const n of Object.values(wf)) {
if (!n || typeof n !== "object") continue;
const ct = n.class_type ?? "";
if (ct === "LoraLoader") add(n.inputs?.lora_name, n.inputs?.strength_model, n.inputs?.strength_clip);
else if (ct === "LoraLoaderModelOnly") add(n.inputs?.lora_name, n.inputs?.strength, 1.0);
}
}
return results;
}
function isTextEncoderNode(ct) { return ct === "CLIPTextEncode" || ct.includes("TextEncode") || ct.includes("TextEncoderSD"); }
function isSamplerNode(ct) { return ct === "KSampler" || ct === "KSamplerAdvanced" || ct.includes("KSampler") || ct.includes("Sampler"); }
function isPromptStylerNode(ct) { return ct.includes("PromptStyler"); }
function extractPrompts(wf) {
if (!wf || typeof wf !== "object") return { positives: [], negatives: [] };
return Array.isArray(wf.nodes) ? extractPromptsLiteGraph(wf) : extractPromptsAPI(wf);
}
// CLIPTextEncode + KSampler でプロンプト抽出(トップレベル・サブグラフ共用)
// サンプラーが見つかりテキストが取れた場合のみ非null を返す
function extractPromptsFromNodeSet(nodes, links) {
const nodeMap = new Map();
for (const n of nodes) nodeMap.set(n.id, n);
const linkOrigin = new Map(), linkSlot = new Map();
if (Array.isArray(links)) {
for (const lk of links) {
if (Array.isArray(lk)) {
linkOrigin.set(lk[0], lk[1]);
linkSlot.set(lk[0], lk[2] ?? 0);
} else if (lk && typeof lk === "object") {
const id = lk.id ?? lk[0], origin = lk.origin_id ?? lk[1], slot = lk.origin_slot ?? lk[2] ?? 0;
if (id != null && origin != null) { linkOrigin.set(id, origin); linkSlot.set(id, slot); }
}
}
}
const textMap = new Map();
for (const n of nodes) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const text = n.widgets_values?.[0];
if (text && typeof text === "string") {
textMap.set(n.id, text);
} else if (Array.isArray(n.inputs)) {
const textInput = n.inputs.find(inp => inp.name === "text" || inp.name === "text_g" || inp.name === "prompt");
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 pos = new Set(), neg = new Set();
let foundSampler = false;
for (const n of nodes) {
if (!isSamplerNode(n.type ?? "") || !Array.isArray(n.inputs)) continue;
foundSampler = true;
for (const inp of n.inputs) {
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);
}
}
if (!foundSampler) return null;
// SamplerCustomAdvanced などで positive/negative 直結がない場合、判別不能テキストとして返す
if (pos.size === 0 && neg.size === 0) {
const allTexts = [...textMap.values()].filter(t => t.trim());
if (allTexts.length > 0) return { positives: [], negatives: [], texts: allTexts };
return null;
}
return { positives: [...pos], negatives: [...neg] };
}
// MarkdownNote の **section** → - [name](url) パターンからモデルを抽出
// flux/qwen/z-image などのサブグラフ形式ワークフロー向け補完用
function extractMarkdownNoteModels(wf) {
const allNodes = [];
if (Array.isArray(wf.nodes)) allNodes.push(...wf.nodes);
for (const sg of wf.definitions?.subgraphs ?? []) if (Array.isArray(sg.nodes)) allNodes.push(...sg.nodes);
const result = { checkpoints: [], vaes: [], diffusionModels: [], textEncoders: [], loras: [] };
const seen = { checkpoints: new Set(), vaes: new Set(), diffusionModels: new Set(), textEncoders: new Set(), loras: new Set() };
function addU(arr, set, name) { if (name && typeof name === "string" && !set.has(name)) { set.add(name); arr.push(name); } }
for (const n of allNodes) {
if (n.type !== "MarkdownNote") continue;
const raw = n.widgets_values;
const text = Array.isArray(raw) ? raw[0] : (typeof raw === "string" ? raw : null);
if (!text) continue;
const sRe = /\*\*([^*\n]+)\*\*/g;
let sm;
while ((sm = sRe.exec(text)) !== null) {
const sec = sm[1].trim().toLowerCase().replace(/\s+/g, "_");
if (!["text_encoders", "diffusion_models", "vae", "checkpoints", "loras"].includes(sec)) continue;
const rest = text.slice(sm.index + sm[0].length);
const end = rest.search(/\n\*\*|\n##/);
const content = end >= 0 ? rest.slice(0, end) : rest;
const lRe = /^- \[([^\]]+)\]/gm;
let lm;
while ((lm = lRe.exec(content)) !== null) {
const name = lm[1].trim();
if (sec === "text_encoders") addU(result.textEncoders, seen.textEncoders, name);
else if (sec === "diffusion_models") addU(result.diffusionModels, seen.diffusionModels, name);
else if (sec === "vae") addU(result.vaes, seen.vaes, name);
else if (sec === "checkpoints") addU(result.checkpoints, seen.checkpoints, name);
else if (sec === "loras") addU(result.loras, seen.loras, name);
}
}
}
const hasAny = result.checkpoints.length || result.vaes.length || result.diffusionModels.length || result.textEncoders.length || result.loras.length;
return hasAny ? result : null;
}
// API形式: リンク参照 [srcNodeId, slot] からテキストを解決
function resolveLinkedText(wf, srcId, slot) {
const src = wf[String(srcId)];
if (!src || typeof src !== "object") return null;
const ct = src.class_type ?? "";
// PromptStyler系: slot 0 = text_positive, slot 1 = text_negative
if (isPromptStylerNode(ct)) {
const v = slot === 0 ? src.inputs?.text_positive : src.inputs?.text_negative;
return (v && typeof v === "string") ? v : null;
}
// 汎用テキストキー
const keys = slot === 0
? ["text_positive", "text", "text_g", "prompt"]
: ["text_negative", "text_l"];
for (const k of keys) {
const v = src.inputs?.[k];
if (v && typeof v === "string") return v;
}
return null;
}
function extractPromptsAPI(wf) {
const metaNodes = Object.values(wf).filter(n => n?.class_type === "ImageMetadataPromptLoader");
if (metaNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of metaNodes) { if (n.inputs?.positive_text) pos.add(n.inputs.positive_text); if (n.inputs?.negative_text) neg.add(n.inputs.negative_text); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
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 (raw && typeof raw === "string") {
textMap.set(id, raw);
} else if (Array.isArray(raw)) {
// リンク参照 [srcNodeId, slot] → 解決
const text = resolveLinkedText(wf, raw[0], raw[1] ?? 0);
if (text) textMap.set(id, text);
}
}
const pos = new Set(), neg = new Set();
let foundSampler = false;
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 ?? {})) {
if (!Array.isArray(val)) continue;
const text = textMap.get(String(val[0]));
if (!text) continue;
if (key === "positive" || key.startsWith("positive")) pos.add(text);
else if (key === "negative" || key.startsWith("negative")) neg.add(text);
}
}
if (!foundSampler || (pos.size === 0 && neg.size === 0)) { const all = [...textMap.values()].filter(t => t && t.trim()); return { positives: [], negatives: [], texts: all }; }
return { positives: [...pos], negatives: [...neg] };
}
function extractPromptsLiteGraph(wf) {
const { nodes, links } = wf;
if (!Array.isArray(nodes)) return { positives: [], negatives: [] };
// 1. ImageMetadataPromptLoader (WFS専用)
const metaNodes = nodes.filter(n => n.type === "ImageMetadataPromptLoader");
if (metaNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of metaNodes) { const p = n.widgets_values?.[2], ng = n.widgets_values?.[3]; if (p) pos.add(p); if (ng) neg.add(ng); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
// 2. WFS_PromptText (WFS専用)
const wfsNodes = nodes.filter(n => n.type === "WFS_PromptText");
if (wfsNodes.length > 0) {
const pos = new Set(), neg = new Set();
for (const n of wfsNodes) { const p = n.widgets_values?.[0], ng = n.widgets_values?.[1]; if (p) pos.add(p); if (ng) neg.add(ng); }
if (pos.size > 0 || neg.size > 0) return { positives: [...pos], negatives: [...neg] };
}
// 3. トップレベルの CLIPTextEncode + KSampler
const topResult = extractPromptsFromNodeSet(nodes, links ?? []);
if (topResult) return topResult;
// 4. PrimitiveStringMultiline(flux2-klein / ernie など)- サブグラフ内も探す
const primTexts = [];
for (const n of collectAllNodes(wf)) {
if (n.type !== "PrimitiveStringMultiline") continue;
const t = Array.isArray(n.widgets_values) ? n.widgets_values[0] : n.widgets_values;
if (t && typeof t === "string" && t.trim()) primTexts.push(t.trim());
}
if (primTexts.length > 0) return { positives: [], negatives: [], texts: primTexts };
// 5. サブグラフ内の CLIPTextEncode + KSampler(z-image / qwen / flux など)
for (const sg of wf.definitions?.subgraphs ?? []) {
if (!Array.isArray(sg.nodes)) continue;
const sgResult = extractPromptsFromNodeSet(sg.nodes, sg.links ?? []);
if (sgResult) return sgResult;
}
// 6. PromptStyler フォールバック
const stylerPos = new Set(), stylerNeg = new Set();
for (const n of nodes) {
if (!isPromptStylerNode(n.type ?? "")) continue;
const vals = n.widgets_values ?? [];
for (let i = 0; i < vals.length; i++) {
if (typeof vals[i] !== "string" || !vals[i].trim()) continue;
if (i % 2 === 0) stylerPos.add(vals[i]);
else stylerNeg.add(vals[i]);
}
}
if (stylerPos.size > 0 || stylerNeg.size > 0) return { positives: [...stylerPos], negatives: [...stylerNeg] };
// 7. テキストエンコーダー全テキスト(最終フォールバック)- サブグラフも含む・判別不能
const all = [];
for (const n of collectAllNodes(wf)) {
if (!isTextEncoderNode(n.type ?? "")) continue;
const t = n.widgets_values?.[0];
if (t && typeof t === "string" && t.trim()) all.push(t);
}
return { positives: [], negatives: [], texts: all };
}
// ── SD/Fooocus prompt extraction ──────────────────────────────
function parseSDAParameters(raw) {
const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const stepsMatch = text.match(/\nSteps:\s+\d/);
if (!stepsMatch) return null;
const paramsStart = stepsMatch.index + 1;
const promptSection = text.slice(0, paramsStart - 1);
const paramsLine = text.slice(paramsStart);
const negSep = "\nNegative prompt: ";
const negIdx = promptSection.indexOf(negSep);
let positive = "", negative = "";
if (negIdx !== -1) { positive = promptSection.slice(0, negIdx).trim(); negative = promptSection.slice(negIdx + negSep.length).trim(); }
else positive = promptSection.trim();
const params = {};
const re = /,?\s*([A-Za-z][A-Za-z0-9 ]*):\s*("(?:[^"\\]|\\.)*"|[^,]+)/g;
let m;
while ((m = re.exec(paramsLine)) !== null) params[m[1].trim()] = m[2].trim().replace(/^"|"$/g, "");
return { positive, negative, params };
}
function parseFooocusMetadata(raw) {
let obj; try { obj = JSON.parse(raw); } catch { return null; }
if (!obj?.base_model) return null;
const toArray = v => !v ? [] : Array.isArray(v) ? v.filter(Boolean) : [String(v)];
return { checkpoint: obj.base_model, vae: (obj.vae && obj.vae !== "Default") ? obj.vae : null, positives: toArray(obj.full_prompt ?? obj.prompt), negatives: toArray(obj.full_negative_prompt ?? obj.negative_prompt) };
}
// ── Master extractAllMetadata ─────────────────────────────────
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) {
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);
if (mdm) {
if (!base.checkpoints.length) base.checkpoints = mdm.checkpoints;
if (!base.vaes.length) base.vaes = mdm.vaes;
if (!base.diffusionModels.length) base.diffusionModels = mdm.diffusionModels;
if (!base.textEncoders.length) base.textEncoders = mdm.textEncoders;
if (!base.loras.length) base.loras = mdm.loras;
}
return base;
}
if (isJSON) {
let wf; try { wf = JSON.parse(sanitizeJSON(await file.text())); } catch { return null; }
return wf ? 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;
}
// 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.fooocus_scheme === "fooocus" && chunks.parameters) {
const f = parseFooocusMetadata(chunks.parameters);
if (!f) return null;
return { source: "fooocus", checkpoints: [f.checkpoint], vaes: f.vae ? [f.vae] : [], diffusionModels: [], textEncoders: [], loras: [], positives: f.positives, negatives: f.negatives };
}
if (chunks.parameters) {
const p = parseSDAParameters(chunks.parameters);
if (!p) return null;
const { positive, negative, params } = p;
const modelName = params["Model"];
if (!modelName) return null;
if (params["Module 2"] != null) {
const textEncoders = [];
for (let i = 2; i <= 9; i++) { const mod = params[`Module ${i}`]; if (!mod) break; textEncoders.push(mod); }
return { source: "sd_forge", checkpoints: [], vaes: params["Module 1"] ? [params["Module 1"]] : [], diffusionModels: [modelName], textEncoders, loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
}
const vaeValue = params["Module 1"] ?? params["VAE"] ?? null;
return { source: "sd", checkpoints: [modelName], vaes: vaeValue ? [vaeValue] : [], diffusionModels: [], textEncoders: [], loras: [], positives: positive ? [positive] : [], negatives: negative ? [negative] : [] };
}
return null;
}
// ── UI helpers ────────────────────────────────────────────────
function escapeHtml(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function buildModelItem(label) {
const el = document.createElement("div");
el.className = "wfm-meta-item";
el.title = label;
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(label)}</span>`;
return el;
}
function buildLoRAItem(lora) {
const el = document.createElement("div");
el.className = "wfm-meta-item";
el.title = lora.name;
const sm = lora.strength_model.toFixed(2);
const sc = lora.strength_clip.toFixed(2);
el.innerHTML = `<span class="wfm-meta-item-name">${escapeHtml(lora.name)}</span><span class="wfm-meta-item-badge">${sm}/${sc}</span>`;
return el;
}
function buildPromptItem(label, type, full, fullArea, fullLabel, listEl) {
const el = document.createElement("div");
el.className = "wfm-meta-item wfm-meta-item-clickable";
const snippet = label.length > 60 ? label.slice(0, 60) + "…" : label;
const typeBadge = type === "positive" ? `<span class="wfm-meta-badge-pos">POS</span>`
: type === "negative" ? `<span class="wfm-meta-badge-neg">NEG</span>`
: ``;
el.innerHTML = `${typeBadge}<span class="wfm-meta-item-name">${escapeHtml(snippet)}</span>`;
el.addEventListener("click", () => {
listEl.querySelectorAll(".wfm-meta-item-clickable").forEach(e => e.classList.remove("selected"));
el.classList.add("selected");
fullArea.value = full;
fullLabel.textContent = type === "positive" ? t("metaPromptPositive")
: type === "negative" ? t("metaPromptNegative")
: t("metaPromptText") || "Text";
});
return el;
}
function renderSection(sectionEl, listEl, items, buildFn) {
listEl.innerHTML = "";
if (items.length === 0) { sectionEl.classList.add("wfm-meta-section-empty"); return; }
sectionEl.classList.remove("wfm-meta-section-empty");
for (const item of items) listEl.appendChild(buildFn(item));
}
// ── Tab initialization ────────────────────────────────────────
export function initMetadataTab() {
const dropZone = document.getElementById("wfm-meta-drop");
const dropLabel = document.getElementById("wfm-meta-drop-label");
const previewImg = document.getElementById("wfm-meta-preview-img");
const fileInfo = document.getElementById("wfm-meta-file-info");
const fileInput = document.getElementById("wfm-meta-file-input");
const ckptSection = document.getElementById("wfm-meta-ckpt-section");
const vaeSection = document.getElementById("wfm-meta-vae-section");
const diffSection = document.getElementById("wfm-meta-diff-section");
const teSection = document.getElementById("wfm-meta-te-section");
const loraSection = document.getElementById("wfm-meta-lora-section");
const promptSection = document.getElementById("wfm-meta-prompt-section");
const ckptList = document.getElementById("wfm-meta-ckpt-list");
const vaeList = document.getElementById("wfm-meta-vae-list");
const diffList = document.getElementById("wfm-meta-diff-list");
const teList = document.getElementById("wfm-meta-te-list");
const loraList = document.getElementById("wfm-meta-lora-list");
const promptList = document.getElementById("wfm-meta-prompt-list");
const promptFull = document.getElementById("wfm-meta-prompt-full");
const promptFullLabel = document.getElementById("wfm-meta-prompt-full-label");
if (!dropZone) return;
// Apply i18n to section titles
const titleMap = {
"wfm-meta-ckpt-section": "metaSectionCkpt",
"wfm-meta-vae-section": "metaSectionVae",
"wfm-meta-diff-section": "metaSectionDiff",
"wfm-meta-te-section": "metaSectionTe",
"wfm-meta-lora-section": "metaSectionLora",
"wfm-meta-prompt-section": "metaSectionPrompt",
};
for (const [id, key] of Object.entries(titleMap)) {
const title = document.querySelector(`#${id} .wfm-meta-section-title`);
if (title) { const tx = t(key); if (tx && tx !== key) title.textContent = tx; }
}
// Apply i18n to format note
const i18nIds = {
"wfm-meta-format-note-title": "metaFormatNoteTitle",
"wfm-meta-fmt-comfyui": "metaFmtComfyui",
"wfm-meta-fmt-sdwebui": "metaFmtSdwebui",
"wfm-meta-fmt-fooocus": "metaFmtFooocus",
"wfm-meta-format-todo": "metaFormatTodo",
};
for (const [id, key] of Object.entries(i18nIds)) {
const el = document.getElementById(id);
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
}
// Apply i18n to help card
const helpIds = {
"wfm-help-metadata-title": "helpMetadataTitle",
"wfm-help-metadata-desc": "helpMetadataDesc",
"wfm-help-metadata-1": "helpMetadata1",
"wfm-help-metadata-2": "helpMetadata2",
"wfm-help-metadata-3": "helpMetadata3",
"wfm-help-metadata-4": "helpMetadata4",
"wfm-help-metadata-5": "helpMetadata5",
};
for (const [id, key] of Object.entries(helpIds)) {
const el = document.getElementById(id);
if (el) { const tx = t(key); if (tx && tx !== key) el.textContent = tx; }
}
function clearAll() {
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => { if (l) l.innerHTML = ""; });
[ckptSection, vaeSection, diffSection, teSection, loraSection, promptSection].forEach(s => { if (s) s.classList.add("wfm-meta-section-empty"); });
if (promptFull) promptFull.value = "";
if (promptFullLabel) promptFullLabel.textContent = "";
}
async function handleFile(file) {
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
fileInfo.textContent = t("metaFileTooLarge");
fileInfo.style.color = "var(--wfm-warning)";
return;
}
fileInfo.textContent = t("metaParsing");
fileInfo.style.color = "var(--wfm-text-secondary)";
clearAll();
// Show preview for images
const isImage = file.type.startsWith("image/") || file.name.toLowerCase().match(/\.(png|webp|jpg|jpeg)$/);
if (isImage) {
const url = URL.createObjectURL(file);
previewImg.src = url;
previewImg.style.display = "block";
dropLabel.style.display = "none";
previewImg.onload = () => URL.revokeObjectURL(url);
} else {
previewImg.style.display = "none";
dropLabel.style.display = "flex";
}
let meta;
try {
meta = await extractAllMetadata(file);
} catch (err) {
console.error("[MetadataTab]", err);
fileInfo.textContent = t("metaParseError");
fileInfo.style.color = "var(--wfm-danger)";
return;
}
if (!meta) {
fileInfo.textContent = t("metaNoMetadata");
fileInfo.style.color = "var(--wfm-warning)";
return;
}
const sizeKB = (file.size / 1024).toFixed(1);
const sourceLabel = { comfyui: "ComfyUI", sd: "SD WebUI", sd_forge: "SD Forge", fooocus: "Fooocus" }[meta.source] ?? meta.source;
fileInfo.textContent = `${file.name} (${sizeKB} KB · ${sourceLabel})`;
fileInfo.style.color = "var(--wfm-text-secondary)";
renderSection(ckptSection, ckptList, meta.checkpoints, n => buildModelItem(n));
renderSection(vaeSection, vaeList, meta.vaes, n => buildModelItem(n));
renderSection(diffSection, diffList, meta.diffusionModels, n => buildModelItem(n));
renderSection(teSection, teList, meta.textEncoders, n => buildModelItem(n));
renderSection(loraSection, loraList, meta.loras, l => buildLoRAItem(l));
// Prompts
promptList.innerHTML = "";
const allPrompts = [
...meta.positives.map(p => ({ type: "positive", text: p })),
...meta.negatives.map(p => ({ type: "negative", text: p })),
...(meta.texts ?? []).map(p => ({ type: "text", text: p })),
];
if (allPrompts.length > 0) {
promptSection.classList.remove("wfm-meta-section-empty");
for (const { type, text } of allPrompts) {
promptList.appendChild(buildPromptItem(text, type, text, promptFull, promptFullLabel, promptList));
}
// Auto-select first positive
const firstPositive = promptList.querySelector(".wfm-meta-item-clickable");
if (firstPositive) firstPositive.click();
} else {
promptSection.classList.add("wfm-meta-section-empty");
}
}
// ── Prompt action buttons ─────────────────────────────────
function getPromptText() { return promptFull?.value ?? ""; }
function flashBtn(btn) {
btn.classList.add("wfm-meta-btn-flash");
setTimeout(() => btn.classList.remove("wfm-meta-btn-flash"), 600);
}
function setTextareaValue(id, text) {
const el = document.getElementById(id);
if (!el) return false;
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
document.getElementById("wfm-meta-copy-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
navigator.clipboard.writeText(text).then(() => flashBtn(this));
});
document.getElementById("wfm-meta-genui-pos-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-prompt-pos-text", text)) flashBtn(this);
});
document.getElementById("wfm-meta-genui-neg-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-prompt-neg-text", text)) flashBtn(this);
});
document.getElementById("wfm-meta-preset-pos-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-preset-pos", text)) flashBtn(this);
});
document.getElementById("wfm-meta-preset-neg-btn")?.addEventListener("click", function () {
const text = getPromptText();
if (!text) return;
if (setTextareaValue("wfm-preset-neg", text)) flashBtn(this);
});
// Drag & Drop
dropZone.addEventListener("dragover", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add("drag-over"); });
dropZone.addEventListener("dragleave", e => { e.stopPropagation(); dropZone.classList.remove("drag-over"); });
dropZone.addEventListener("drop", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); handleFile(e.dataTransfer.files?.[0]); });
dropZone.addEventListener("click", e => { if (e.target === previewImg) return; fileInput.click(); });
fileInput.addEventListener("change", () => { handleFile(fileInput.files?.[0]); fileInput.value = ""; });
// Prevent scroll passthrough in lists
[ckptList, vaeList, diffList, teList, loraList, promptList].forEach(l => {
if (l) l.addEventListener("wheel", e => e.stopPropagation(), { passive: true });
});
clearAll();
}
+770 -128
View File
File diff suppressed because it is too large Load Diff
+127 -118
View File
@@ -8,7 +8,6 @@ import { t } from "./i18n.js";
// ── State ─────────────────────────────────────────────────
const NODES_PER_PAGE = 50;
const state = {
allNodes: [],
@@ -257,91 +256,50 @@ function renderNodeGrid() {
if (filtered.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
clearPagination();
return;
}
if (state.viewMode === "table") {
renderNodeTableView(grid, filtered);
clearPagination();
return;
}
// Card view with pagination
const totalPages = Math.ceil(filtered.length / NODES_PER_PAGE);
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
if (state.currentPage < 0) state.currentPage = 0;
const start = state.currentPage * NODES_PER_PAGE;
const pageItems = filtered.slice(start, start + NODES_PER_PAGE);
grid.innerHTML = "";
pageItems.forEach(node => {
const meta = state.nodeMetadata[node.name] || {};
const card = document.createElement("div");
card.className = "wfm-card wfm-node-card";
if (state.selectedNode && state.selectedNode.name === node.name) {
card.classList.add("wfm-card-selected");
}
card.dataset.nodeName = node.name;
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
filtered.forEach(node => grid.appendChild(createNodeCard(node)));
}
const inputCount = Object.keys(node.input.required || {}).length +
Object.keys(node.input.optional || {}).length;
const outputCount = (node.output || []).length;
const tags = (meta.tags || []).map(tg => `<span class="wfm-tag">#${escapeHtml(tg)}</span>`).join("");
const favStar = meta.favorite ? "\u2605" : "\u2606";
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
function createNodeCard(node) {
const meta = state.nodeMetadata[node.name] || {};
const card = document.createElement("div");
card.className = "wfm-card wfm-node-card";
if (state.selectedNode && state.selectedNode.name === node.name) {
card.classList.add("wfm-card-selected");
}
card.dataset.nodeName = node.name;
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
card.innerHTML = `
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
<div class="wfm-card-meta">
${categoryBadgeHtml(node.category)}
</div>
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
const tags = (meta.tags || []).map(tg => `<span class="wfm-tag">#${escapeHtml(tg)}</span>`).join("");
const favStar = meta.favorite ? "\u2605" : "\u2606";
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
card.innerHTML = `
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
<div class="wfm-card-meta">
${categoryBadgeHtml(node.category)}
</div>
<button class="${favClass}" title="Favorite">${favStar}</button>`;
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
</div>
<button class="${favClass}" title="Favorite">${favStar}</button>`;
card.querySelector(".wfm-fav-btn").addEventListener("click", e => {
e.stopPropagation();
toggleFavorite(node.name, e.currentTarget);
});
card.addEventListener("click", () => showNodeSidePanel(node));
grid.appendChild(card);
card.querySelector(".wfm-fav-btn").addEventListener("click", e => {
e.stopPropagation();
toggleFavorite(node.name, e.currentTarget);
});
renderPagination(filtered.length, totalPages);
card.addEventListener("click", () => showNodeSidePanel(node));
return card;
}
function renderPagination(totalItems, totalPages) {
const container = document.getElementById("wfm-nodes-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
const start = state.currentPage * NODES_PER_PAGE + 1;
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${start}-${end} / ${totalItems}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
});
}
function clearPagination() {
const container = document.getElementById("wfm-nodes-pagination");
if (container) container.innerHTML = "";
}
function scrollGridToTop() {
document.getElementById("wfm-nodes-grid")?.scrollTo(0, 0);
}
function renderNodeTableView(grid, filtered) {
grid.innerHTML = "";
@@ -399,9 +357,6 @@ async function toggleFavorite(nodeName, btnEl) {
function showNodeSidePanel(node) {
state.selectedNode = node;
const panel = document.getElementById("wfm-nodes-side-panel");
if (!panel) return;
panel.style.display = "flex";
// Highlight selected card
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
@@ -545,42 +500,61 @@ function renderSideGroups(node) {
const el = document.getElementById("wfm-nodes-side-group");
if (!el) return;
const groupNames = Object.keys(state.nodeGroups).sort();
const memberOf = groupNames.filter(g => (state.nodeGroups[g] || []).includes(node.name));
const allGroups = Object.keys(state.nodeGroups).sort();
const memberOf = allGroups.filter(g => (state.nodeGroups[g] || []).includes(node.name));
const available = allGroups.filter(g => !memberOf.includes(g));
let html = `<h4 style="margin:0 0 8px;">${t("nodesGroups")}</h4>`;
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:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`
).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${available.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div style="margin-bottom:12px;">
<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-nodes-new-group-input" class="wfm-input"
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
</div>
</div>
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-nodes-manage-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-nodes-rename-group-btn" class="wfm-btn wfm-btn-sm"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">&#9998;</button>
<button id="wfm-nodes-delete-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">&times;</button>
</div>
</div>
</div>
`;
// Current groups
if (memberOf.length) {
html += memberOf.map(g =>
`<div class="wfm-node-group-item">
<span>${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}">${t("nodesRemoveGroup")}</button>
</div>`
).join("");
} else {
html += `<p style="color:var(--wfm-text-secondary);font-size:12px;">No groups assigned</p>`;
}
// Assign to group
const available = groupNames.filter(g => !memberOf.includes(g));
html += `<div style="margin-top:12px;display:flex;gap:4px;">
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;">
<option value="">${t("nodesAssignGroup")}</option>
${available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+</button>
</div>`;
// Create new group
html += `<div style="margin-top:8px;display:flex;gap:4px;">
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input" style="flex:1;" placeholder="${t("nodesGroupName")}">
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm">${t("nodesCreateGroup")}</button>
</div>`;
el.innerHTML = html;
// Event listeners
// グループから除外
el.querySelectorAll(".wfm-node-remove-group").forEach(btn => {
btn.addEventListener("click", async () => {
const group = btn.dataset.group;
@@ -592,8 +566,9 @@ function renderSideGroups(node) {
});
});
document.getElementById("wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
const sel = document.getElementById("wfm-nodes-assign-group-select");
// グループに追加
el.querySelector("#wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
const sel = el.querySelector("#wfm-nodes-assign-group-select");
const group = sel?.value;
if (!group) return;
if (!state.nodeGroups[group]) state.nodeGroups[group] = [];
@@ -605,25 +580,61 @@ function renderSideGroups(node) {
}
});
document.getElementById("wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
const input = document.getElementById("wfm-nodes-new-group-input");
// グループ作成(作成後に現在のノードに追加)
el.querySelector("#wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
const input = el.querySelector("#wfm-nodes-new-group-input");
const name = input?.value?.trim();
if (!name) return;
if (!state.nodeGroups[name]) state.nodeGroups[name] = [];
if (!state.nodeGroups[name].includes(node.name)) {
state.nodeGroups[name].push(node.name);
if (state.nodeGroups[name]) {
showToast("Group already exists", "warning");
return;
}
state.nodeGroups[name] = [node.name];
await saveNodeGroups(state.nodeGroups);
input.value = "";
renderSideGroups(node);
renderFilters();
});
// グループ名変更
el.querySelector("#wfm-nodes-rename-group-btn")?.addEventListener("click", async () => {
const sel = el.querySelector("#wfm-nodes-manage-group-select");
const oldName = sel?.value;
if (!oldName) return;
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (state.nodeGroups[newName] !== undefined) {
showToast("Group name already exists", "warning");
return;
}
state.nodeGroups[newName] = state.nodeGroups[oldName];
delete state.nodeGroups[oldName];
await saveNodeGroups(state.nodeGroups);
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroups(node);
renderFilters();
showToast(`Renamed to "${newName}"`, "success");
});
// グループ削除
el.querySelector("#wfm-nodes-delete-group-btn")?.addEventListener("click", async () => {
const sel = el.querySelector("#wfm-nodes-manage-group-select");
const name = sel?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
delete state.nodeGroups[name];
await saveNodeGroups(state.nodeGroups);
if (state.groupFilter === name) state.groupFilter = "";
renderSideGroups(node);
renderFilters();
showToast(`Group "${name}" deleted`, "success");
});
}
function closeNodeSidePanel() {
const panel = document.getElementById("wfm-nodes-side-panel");
if (panel) panel.style.display = "none";
state.selectedNode = null;
const titleEl = document.getElementById("wfm-nodes-panel-title");
if (titleEl) titleEl.textContent = "";
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
el.classList.remove("wfm-card-selected");
});
@@ -1083,8 +1094,6 @@ export function initNodesTab() {
loadNodesData();
});
// Side panel close
document.getElementById("wfm-nodes-panel-close")?.addEventListener("click", closeNodeSidePanel);
// Side panel tab switching
document.querySelectorAll(".wfm-nodes-side-tab-btn").forEach(btn => {
+380
View File
@@ -593,6 +593,233 @@ function applyToGenerateUI(text) {
showToast(t("appliedToGenerateUI"), "success");
}
// ============================================
// Wildcard API helpers
// ============================================
async function wcFetchFiles() {
try {
const res = await fetch("/api/wfm/wildcards");
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function wcFetchContent(filename) {
try {
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
const data = await res.json();
return data.content ?? null;
} catch { return null; }
}
async function wcSaveFile(filename, content) {
try {
const res = await fetch("/api/wfm/wildcards/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, content }),
});
const data = await res.json();
return data.status === "ok" ? data.file : null;
} catch { return null; }
}
async function wcDeleteFile(filename) {
try {
await fetch("/api/wfm/wildcards/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
} catch { /* ignore */ }
}
// ============================================
// Wildcard state & helpers
// ============================================
let wcFiles = [];
let wcEditingFilename = null; // null = new file
// Insert text at cursor; if open+close provided wraps selection
function wcInsertAtCursor(textarea, open, close = "") {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
let inserted, cursorStart, cursorEnd;
if (selected && close) {
inserted = open + selected + close;
cursorStart = start + open.length;
cursorEnd = cursorStart + selected.length;
} else if (close) {
inserted = open + close;
cursorStart = start + open.length;
cursorEnd = cursorStart;
} else {
inserted = open;
cursorStart = start + open.length;
cursorEnd = cursorStart;
}
textarea.value =
textarea.value.substring(0, start) +
inserted +
textarea.value.substring(end);
textarea.setSelectionRange(cursorStart, cursorEnd);
textarea.focus();
textarea.dispatchEvent(new Event("input"));
}
// ============================================
// Wildcard file list rendering
// ============================================
function wcRenderFileList() {
const list = document.getElementById("wfm-wc-file-list");
if (!list) return;
list.innerHTML = "";
if (wcFiles.length === 0) {
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
return;
}
// Group by directory; root ("") first, then alphabetical
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
function addFileItem(f, indented) {
const item = document.createElement("div");
item.className = "wfm-wc-file-item" + (indented ? " wfm-wc-file-item--sub" : "");
item.innerHTML = `
<div class="wfm-wc-file-item-info">
<span class="wfm-wc-file-name">${esc(f.name)}</span>
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
</div>
<div class="wfm-wc-file-item-actions">
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.wc_name)}__ at cursor">Use</button>
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">&#9998;</button>
</div>
`;
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
e.stopPropagation();
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const content = await wcFetchContent(f.filename);
wcOpenEditor(f.wc_name, f.ext, content ?? "", f.filename);
});
item.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
});
list.appendChild(item);
}
for (const dir of dirs) {
if (dir) {
const header = document.createElement("div");
header.className = "wfm-wc-dir-header";
header.textContent = dir + "/";
list.appendChild(header);
}
for (const f of grouped[dir]) {
addFileItem(f, dir !== "");
}
}
}
// ============================================
// Wildcard file editor
// ============================================
function wcOpenEditor(name, ext, content, existingFilename) {
wcEditingFilename = existingFilename || null;
const editor = document.getElementById("wfm-wc-editor");
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
if (!editor) return;
nameInput.value = name || "";
extSelect.value = ext || "txt";
contentTA.value = content || "";
deleteBtn.style.display = existingFilename ? "" : "none";
editor.style.display = "";
nameInput.focus();
}
function wcCloseEditor() {
wcEditingFilename = null;
const editor = document.getElementById("wfm-wc-editor");
if (editor) editor.style.display = "none";
}
async function wcRefreshFiles() {
wcFiles = await wcFetchFiles();
wcRenderFileList();
wcUpdateFilePicker();
}
// ============================================
// Wildcard file picker popup
// ============================================
function wcUpdateFilePicker() {
const picker = document.getElementById("wfm-wc-file-picker");
if (!picker) return;
picker.innerHTML = "";
if (wcFiles.length === 0) {
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
return;
}
// Group by directory same as file list
const grouped = {};
for (const f of wcFiles) {
const dir = f.dir || "";
if (!grouped[dir]) grouped[dir] = [];
grouped[dir].push(f);
}
const dirs = Object.keys(grouped).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});
for (const dir of dirs) {
if (dir) {
const sep = document.createElement("div");
sep.className = "wfm-wc-picker-dir";
sep.textContent = dir + "/";
picker.appendChild(sep);
}
for (const f of grouped[dir]) {
const btn = document.createElement("button");
btn.className = "wfm-wc-picker-item" + (dir ? " wfm-wc-picker-item--sub" : "");
btn.textContent = `__${f.wc_name}__`;
btn.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) wcInsertAtCursor(ta, `__${f.wc_name}__`);
picker.style.display = "none";
});
picker.appendChild(btn);
}
}
}
// ============================================
// Initialize
// ============================================
@@ -853,4 +1080,157 @@ export function initPromptTab() {
pmSearchText = e.target.value.trim();
renderPresetManager();
});
// ── Center column tab switching ──────────────────────────
document.querySelectorAll(".wfm-prompt-center-tab").forEach(btn => {
btn.addEventListener("click", () => {
const target = btn.dataset.ptab;
document.querySelectorAll(".wfm-prompt-center-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".wfm-prompt-center-pane").forEach(p => {
p.style.display = p.id === `wfm-ptab-${target}` ? "" : "none";
});
});
});
// ── Wildcard syntax buttons ──────────────────────────────
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
btn.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const open = btn.dataset.wcOpen || "";
const close = btn.dataset.wcClose || "";
wcInsertAtCursor(ta, open, close);
});
});
// LoRA LBW template button
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
// Move cursor to after "lora:" for the name
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
ta.setSelectionRange(pos, pos);
ta.focus();
});
// n$${ } button — asks for n then inserts multi-pick template
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (!ta) return;
const n = prompt("Number of items to pick (n):", "2");
if (n === null) return;
const num = parseInt(n, 10);
if (isNaN(num) || num < 1) { showToast("Enter a positive integer", "error"); return; }
const template = "{" + num + "$$|}";
const start = ta.selectionStart;
wcInsertAtCursor(ta, template);
// Move cursor between $$ and |
const inner = start + ("{" + num + "$$").length;
ta.setSelectionRange(inner, inner);
ta.focus();
});
// File picker toggle
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
const filePicker = document.getElementById("wfm-wc-file-picker");
filePickBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (!filePicker) return;
const isOpen = filePicker.style.display !== "none";
filePicker.style.display = isOpen ? "none" : "";
if (!isOpen) wcUpdateFilePicker();
});
document.addEventListener("click", () => {
if (filePicker) filePicker.style.display = "none";
});
// ── Wildcard prompt actions ──────────────────────────────
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
const text = ta?.value || "";
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
});
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-pos");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast("Sent to Positive Prompt", "success");
});
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
const src = document.getElementById("wfm-wc-prompt");
const dst = document.getElementById("wfm-preset-neg");
if (!src || !dst) return;
dst.value = src.value;
dst.dispatchEvent(new Event("input", { bubbles: true }));
showToast("Sent to Negative Prompt", "success");
});
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
const ta = document.getElementById("wfm-wc-prompt");
if (ta) { ta.value = ""; ta.focus(); }
});
// ── Wildcard file manager ────────────────────────────────
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
wcOpenEditor("", "txt", "", null);
});
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
wcRefreshFiles();
});
// Editor: save
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
const nameInput = document.getElementById("wfm-wc-editor-name");
const extSelect = document.getElementById("wfm-wc-editor-ext");
const contentTA = document.getElementById("wfm-wc-editor-content");
// Normalize separators and strip leading/trailing slashes
const rawName = (nameInput?.value || "").trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
const ext = extSelect?.value || "txt";
const content = contentTA?.value || "";
if (!rawName) { showToast("Enter a filename", "error"); return; }
// Validate each path component (allow letters, numbers, -, _, ., space)
const nameParts = rawName.split("/");
if (nameParts.some(p => !p || !/^[\w\-. ]+$/.test(p))) {
showToast("Invalid path. Use folder/name format, each part: letters, numbers, -, _, . only", "error");
return;
}
const name = rawName;
const filename = `${name}.${ext}`;
const saved = await wcSaveFile(filename, content);
if (saved) {
showToast(`Saved: ${filename}`, "success");
wcCloseEditor();
wcRefreshFiles();
} else {
showToast("Failed to save file", "error");
}
});
// Editor: delete
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
if (!wcEditingFilename) return;
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
await wcDeleteFile(wcEditingFilename);
showToast(`Deleted: ${wcEditingFilename}`, "success");
wcCloseEditor();
wcRefreshFiles();
});
// Editor: cancel
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
wcCloseEditor();
});
// Initial wildcard file load
wcRefreshFiles();
}
+373 -19
View File
@@ -314,6 +314,60 @@ function clearCustomOverrides() {
if (fontEl) fontEl.remove();
}
const TEXTAREA_FONT_SIZE_DEFAULT = 13;
const TEXTAREA_FONT_SIZE_STYLE_ID = "wfm-ta-font-size-style";
const TEXTAREA_FONT_SIZE_TARGETS = [
"#wfm-prompt-pos-text",
"#wfm-prompt-neg-text",
"#wfm-ollama-input",
"#wfm-preset-pos",
"#wfm-preset-neg",
"#wfm-wc-prompt",
"#wfm-wc-editor-content",
"#wfm-meta-prompt-full",
].join(",\n ");
export function applyTextareaFontSize(size) {
const px = Math.max(10, Math.min(28, parseInt(size) || TEXTAREA_FONT_SIZE_DEFAULT));
let el = document.getElementById(TEXTAREA_FONT_SIZE_STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = TEXTAREA_FONT_SIZE_STYLE_ID;
document.head.appendChild(el);
}
el.textContent = `${TEXTAREA_FONT_SIZE_TARGETS} { font-size: ${px}px !important; }`;
}
// JSON highlight color settings
const JSON_COLOR_STYLE_ID = "wfm-json-color-style";
const JSON_COLOR_DEFS = [
{ id: "base", label: () => t("jsonColorBase") || "通常テキスト", def: "#abb2bf" },
{ id: "yellow", label: () => t("jsonColorYellow") || "名前 / スケジューラ", def: "#e5c07b" },
{ id: "pink", label: () => t("jsonColorPink") || "タイトル", def: "#c678dd" },
{ id: "green", label: () => t("jsonColorGreen") || "Width / Height", def: "#98c379" },
{ id: "cyan", label: () => t("jsonColorCyan") || "プロンプト / テキスト", def: "#61afef" },
{ id: "red", label: () => t("jsonColorRed") || "画像 / ファイル", def: "#e06c75" },
];
export function applyJsonColors(colors) {
const vals = Object.fromEntries(JSON_COLOR_DEFS.map(d => [d.id, colors?.[d.id] || d.def]));
let el = document.getElementById(JSON_COLOR_STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = JSON_COLOR_STYLE_ID;
document.head.appendChild(el);
}
el.textContent = [
`.wfm-json-highlight { color: ${vals.base}; }`,
`.json-key-yellow, .json-val-yellow { color: ${vals.yellow}; }`,
`.json-key-pink, .json-val-pink { color: ${vals.pink}; }`,
`.json-key-green, .json-val-green { color: ${vals.green}; }`,
`.json-key-cyan, .json-val-cyan { color: ${vals.cyan}; }`,
`.json-key-red, .json-val-red { color: ${vals.red}; }`,
].join("\n");
}
export function applyTheme(themeId) {
if (themeId) {
document.documentElement.setAttribute("data-theme", themeId);
@@ -346,6 +400,12 @@ export async function initSettingsTab() {
workflowsDirInfo = await res.json();
} catch {}
// Load gallery output dir info
let outputDirInfo = { current: "", default: "", saved: "" };
try {
const res = await fetch("/api/wfm/settings/output-dir");
outputDirInfo = await res.json();
} catch {}
const uiLang = getLang();
const summaryLang = getSummaryLang();
@@ -353,26 +413,10 @@ export async function initSettingsTab() {
container.innerHTML = `
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
<!-- Language Settings -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
${buildLangOptions(getLanguageOptions(), uiLang)}
</select>
</div>
<div class="wfm-form-group">
<label>${t("summaryLangLabel")}</label>
<select class="wfm-select" id="wfm-settings-summary-lang">
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
</select>
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
⚠ ${t("summaryLangNote")}
</small>
</div>
</details>
<div class="wfm-settings-layout">
<!-- Right column: Theme (floated right) -->
<div class="wfm-settings-right-col">
<!-- Theme -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
@@ -471,6 +515,65 @@ export async function initSettingsTab() {
</div>
</details>
</div><!-- /wfm-settings-right-col -->
<!-- Left column: all settings (Language + rest), flows left of floated Theme -->
<div class="wfm-settings-left-col">
<!-- Language Settings -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
${buildLangOptions(getLanguageOptions(), uiLang)}
</select>
</div>
<div class="wfm-form-group">
<label>${t("summaryLangLabel")}</label>
<select class="wfm-select" id="wfm-settings-summary-lang">
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
</select>
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
⚠ ${t("summaryLangNote")}
</small>
</div>
</details>
<!-- Text Size -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("textSizeLabel") || "Text Size"}</summary>
<div class="wfm-form-group">
<label style="display:flex;align-items:center;justify-content:space-between;">
<span>${t("textSizeTextarea") || "Prompt / Chat Textarea"}</span>
<span id="wfm-ta-font-size-val" style="font-size:12px;color:var(--wfm-text-secondary);">${settings.textareaFontSize || TEXTAREA_FONT_SIZE_DEFAULT}px</span>
</label>
<input type="range" class="wfm-range" id="wfm-ta-font-size"
min="10" max="28" step="1"
value="${settings.textareaFontSize || TEXTAREA_FONT_SIZE_DEFAULT}"
style="width:100%;margin-top:4px;">
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("textSizeHint") || "Applies to: Generate UI prompts, AI Assistant chat, Preset prompts, Wildcard textarea, Metadata prompt preview"}
</small>
</div>
</details>
<!-- RAW JSON Colors -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("jsonColorsSection") || "RAW JSON Colors"}</summary>
<div class="wfm-form-group">
<div class="wfm-color-grid" id="wfm-json-color-grid">
${JSON_COLOR_DEFS.map(d => {
const saved = settings.jsonColors?.[d.id] || d.def;
return `<div class="wfm-color-item">
<label>${d.label()}</label>
<input type="color" data-json-color="${d.id}" value="${saved}">
</div>`;
}).join("")}
</div>
<button class="wfm-btn wfm-btn-sm" id="wfm-json-color-reset" style="margin-top:8px;">${t("jsonColorReset") || "デフォルトに戻す"}</button>
</div>
</details>
<!-- Workflow Data Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
@@ -492,6 +595,27 @@ export async function initSettingsTab() {
</div>
</details>
<!-- Gallery Output Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
<div class="wfm-form-group">
<label>${t("galleryOutputDirLabel")}</label>
<div style="display:flex;gap:8px;">
<input type="text" class="wfm-input" id="wfm-settings-output-dir"
value="${serverSettings.gallery_output_dir || ""}"
placeholder="${outputDirInfo.default}">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-output-dir-apply">${t("workflowsDirApply")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-output-dir-reset">${t("workflowsDirDefault")}</button>
</div>
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("galleryOutputDirHint")}
</small>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;">
${t("workflowsDirCurrent")}: <code id="wfm-settings-output-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${outputDirInfo.current}</code>
</div>
</div>
</details>
<!-- ComfyUI Settings -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
@@ -571,8 +695,33 @@ export async function initSettingsTab() {
</div>
</details>
<!-- Data Management -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
${t("importData")}
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
</label>
</div>
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
</details>
<!-- Wildcard Integration -->
<details class="wfm-settings-section" id="wfm-wildcard-integration-section">
<summary class="wfm-settings-summary">Wildcard Integration</summary>
<div id="wfm-wildcard-link-status" style="font-size:13px;color:var(--wfm-text-secondary);">Loading...</div>
</details>
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
</div><!-- /wfm-settings-left-col -->
<div style="clear:both;"></div>
</div><!-- /wfm-settings-layout -->
`;
// --- Theme change handler ---
@@ -828,6 +977,50 @@ export async function initSettingsTab() {
}
});
// --- Gallery output dir handlers ---
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
const dirInput = document.getElementById("wfm-settings-output-dir");
const currentEl = document.getElementById("wfm-settings-output-dir-current");
const newDir = dirInput.value.trim();
try {
const res = await fetch("/api/wfm/settings/output-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gallery_output_dir: newDir }),
});
const data = await res.json();
if (data.error) {
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
} else {
if (currentEl) currentEl.textContent = data.current;
showToast(t("workflowsDirChanged"), "success");
// gallery-tab に通知して再ロード
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
}
} catch (err) {
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
}
});
document.getElementById("wfm-settings-output-dir-reset")?.addEventListener("click", async () => {
const dirInput = document.getElementById("wfm-settings-output-dir");
const currentEl = document.getElementById("wfm-settings-output-dir-current");
dirInput.value = "";
try {
const res = await fetch("/api/wfm/settings/output-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gallery_output_dir: "" }),
});
const data = await res.json();
if (currentEl) currentEl.textContent = data.current;
showToast(t("workflowsDirChanged"), "success");
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
} catch (err) {
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
}
});
// --- Ollama model list loader ---
async function loadOllamaModels() {
const select = document.getElementById("wfm-settings-ollama-model");
@@ -928,8 +1121,38 @@ export async function initSettingsTab() {
showToast(t("defaultWorkflowCleared"), "success");
});
// --- Textarea font size slider ---
document.getElementById("wfm-ta-font-size")?.addEventListener("input", (e) => {
const px = parseInt(e.target.value);
const valEl = document.getElementById("wfm-ta-font-size-val");
if (valEl) valEl.textContent = `${px}px`;
applyTextareaFontSize(px);
});
// --- JSON highlight color pickers ---
document.getElementById("wfm-json-color-grid")?.addEventListener("input", (e) => {
if (e.target.type !== "color") return;
const id = e.target.dataset.jsonColor;
const jsonColors = { ...(loadLocalSettings().jsonColors || {}), [id]: e.target.value };
saveLocalSettings({ jsonColors });
applyJsonColors(jsonColors);
});
document.getElementById("wfm-json-color-reset")?.addEventListener("click", () => {
const cur = loadLocalSettings();
delete cur.jsonColors;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyJsonColors(undefined);
document.querySelectorAll("#wfm-json-color-grid input[type=color]").forEach(inp => {
const def = JSON_COLOR_DEFS.find(d => d.id === inp.dataset.jsonColor);
if (def) inp.value = def.def;
});
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;
const patch = {
comfyuiUrl: document.getElementById("wfm-settings-comfyui-url")?.value.trim() || "",
eagleUrl: document.getElementById("wfm-settings-eagle-url")?.value.trim() || "http://localhost:41595",
@@ -937,6 +1160,7 @@ export async function initSettingsTab() {
uiLang: getLang(),
summaryLang: getSummaryLang(),
theme: document.documentElement.getAttribute("data-theme") || "",
textareaFontSize: taFontSize,
};
saveLocalSettings(patch);
@@ -952,4 +1176,134 @@ export async function initSettingsTab() {
if (savedUrl) {
comfyUI.updateUrl(savedUrl);
}
// --- Wildcard Integration ---
async function wcLoadLinkStatus() {
const el = document.getElementById("wfm-wildcard-link-status");
if (!el) return;
try {
const res = await fetch("/api/wfm/wildcards/link-status");
const data = await res.json();
wcRenderLinkStatus(el, data);
} catch (e) {
el.innerHTML = `<span style="color:var(--wfm-error,#f44336)">Error: ${e.message}</span>`;
}
}
function wcRenderLinkStatus(el, data) {
if (!data.impact_pack_installed) {
el.innerHTML = `
<p style="margin:0 0 8px;">ComfyUI-Impact-Pack がインストールされていません。</p>
<p style="margin:0;font-size:12px;color:var(--wfm-text-secondary);">
ワイルドカードを Impact Pack と共有するには、先に
<a href="https://github.com/ltdrdata/ComfyUI-Impact-Pack" target="_blank" rel="noopener"
style="color:var(--wfm-accent);">ComfyUI-Impact-Pack</a>
をインストールしてください。
</p>`;
return;
}
if (data.is_linked) {
el.innerHTML = `
<p style="margin:0 0 4px;color:var(--wfm-success,#4caf50);">✓ リンク済み</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
WFS wildcard → ${data.link_target || data.impact_pack_wildcards_dir}
</p>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-wc-remove-link">リンクを解除</button>
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
document.getElementById("wfm-wc-remove-link")?.addEventListener("click", async () => {
const msg = document.getElementById("wfm-wc-link-msg");
try {
const r = await fetch("/api/wfm/wildcards/remove-link", { method: "POST" });
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
msg.textContent = "リンクを解除しました。";
msg.style.color = "var(--wfm-success,#4caf50)";
setTimeout(wcLoadLinkStatus, 800);
} catch (e) {
msg.textContent = "エラー: " + e.message;
msg.style.color = "var(--wfm-error,#f44336)";
}
});
} else {
el.innerHTML = `
<p style="margin:0 0 4px;">Impact Pack wildcards ディレクトリ:</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">${data.impact_pack_wildcards_dir}</p>
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
WFS の wildcard ディレクトリを Impact Pack の wildcards ディレクトリへのリンクに置き換えます。
既存の WFS ワイルドカードファイルは Impact Pack ディレクトリへ移行されます。
</p>
<button class="wfm-btn wfm-btn-sm" id="wfm-wc-create-link">リンクを作成</button>
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
document.getElementById("wfm-wc-create-link")?.addEventListener("click", async () => {
const msg = document.getElementById("wfm-wc-link-msg");
try {
const r = await fetch("/api/wfm/wildcards/create-link", { method: "POST" });
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
const migrated = d.migrated_files?.length
? ` (移行: ${d.migrated_files.join(", ")})`
: "";
msg.textContent = "リンクを作成しました。" + migrated;
msg.style.color = "var(--wfm-success,#4caf50)";
setTimeout(wcLoadLinkStatus, 800);
} catch (e) {
msg.textContent = "エラー: " + e.message;
msg.style.color = "var(--wfm-error,#f44336)";
}
});
}
}
// Load wildcard link status when section is opened
document.getElementById("wfm-wildcard-integration-section")?.addEventListener("toggle", (e) => {
if (e.target.open) wcLoadLinkStatus();
});
// --- Data Export ---
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-data-status");
try {
const res = await fetch("/api/wfm/settings/export");
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "wfm-data-export.json";
a.click();
URL.revokeObjectURL(url);
statusEl.textContent = t("exportSuccess");
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("exportError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
});
// --- Data Import ---
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
const statusEl = document.getElementById("wfm-settings-data-status");
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const bundle = JSON.parse(text);
const res = await fetch("/api/wfm/settings/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bundle),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || res.statusText);
const imported = result.imported?.join(", ") || "-";
statusEl.textContent = t("importSuccess") + ": " + imported;
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
// Reset file input so same file can be re-selected
e.target.value = "";
});
}
+250 -282
View File
@@ -9,6 +9,7 @@ import { comfyWorkflow } from "./comfyui-workflow.js";
import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { t, getSummaryPrompt } from "./i18n.js";
import { highlightJSON } from "./json-highlight.js";
import { openBadgeEditModal } from "./models-tab.js";
// ============================================
// State
@@ -16,29 +17,31 @@ import { highlightJSON } from "./json-highlight.js";
const state = {
workflows: [],
activeModel: "ALL",
activeBadge: "ALL",
searchText: "",
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
selectedWf: null,
groupFilter: "", // empty = all groups
currentPage: 0,
badgeColors: (() => {
try { return JSON.parse(localStorage.getItem("wfm_badge_colors") || "{}"); }
catch { return {}; }
})(),
};
const WF_PER_PAGE = 24;
const BADGE_DEFAULT_COLORS = {
"SD1.5": "#e67e22",
SDXL: "#2ecc71",
Flux: "#9b59b6",
Flux2: "#8e44ad",
SD3: "#3498db",
Qwen: "#e74c3c",
"Z-IMAGE": "#1abc9c",
};
function getBadgePalette() {
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
catch { return {}; }
}
function saveBadgePalette(palette) {
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ============================================
// Open in ComfyUI
@@ -217,15 +220,22 @@ async function getRawWorkflow(filename) {
// Rendering Helpers
// ============================================
function badgeHtml(type) {
const color = state.badgeColors[type] || BADGE_DEFAULT_COLORS[type] || "#0077ff";
return `<span class="wfm-badge wfm-badge-model" style="background:${color}">${type}</span>`;
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>`;
}
function getAllModelTypes() {
function wfBadgesHtml(wf) {
const badges = wf.metadata?.badges || [];
return badges.map(label => badgeHtml(label)).join("");
}
function getAllBadges() {
const set = new Set();
state.workflows.forEach((wf) =>
(wf.analysis.modelTypes || []).forEach((t) => set.add(t))
(wf.metadata?.badges || []).forEach((b) => set.add(b))
);
return [...set].sort();
}
@@ -234,17 +244,22 @@ function renderModelFilters() {
const container = document.getElementById("wfm-model-filters");
if (!container) return;
const types = getAllModelTypes();
const palette = getBadgePalette();
const badges = Object.keys(palette).sort();
container.innerHTML = `
<button class="wfm-filter-btn ${state.activeModel === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
<button class="wfm-filter-btn ${state.activeModel === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">&#9733;</button>
${types.map((t) => `<button class="wfm-filter-btn ${state.activeModel === t ? "active" : ""}" data-filter="${t}">${t}</button>`).join("")}
<button class="wfm-filter-btn ${state.activeBadge === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
<button class="wfm-filter-btn ${state.activeBadge === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">&#9733;</button>
${badges.map((b) => {
const color = palette[b] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
return `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}"${style}>${escapeHtml(b)}</button>`;
}).join("")}
`;
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.activeModel = btn.dataset.filter;
state.activeBadge = btn.dataset.filter;
state.currentPage = 0;
renderModelFilters();
renderGrid();
@@ -254,10 +269,12 @@ function renderModelFilters() {
function filterWorkflows() {
return state.workflows.filter((wf) => {
if (state.activeModel === "FAVORITE") {
if (wf.filename === ".index.json") return false;
if (state.activeBadge === "FAVORITE") {
if (!wf.metadata.favorite) return false;
} else if (state.activeModel !== "ALL") {
if (!(wf.analysis.modelTypes || []).includes(state.activeModel))
} else if (state.activeBadge !== "ALL") {
if (!(wf.metadata?.badges || []).includes(state.activeBadge))
return false;
}
@@ -301,32 +318,21 @@ function renderGrid() {
if (filtered.length === 0) {
grid.innerHTML =
'<p class="wfm-placeholder">No workflows found</p>';
updateWfPagination(0);
return;
}
if (state.viewMode === "table") {
renderTableView(grid, filtered);
updateWfPagination(0);
return;
}
// Thumbnail / Card view with pagination
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
if (state.currentPage < 0) state.currentPage = 0;
const start = state.currentPage * WF_PER_PAGE;
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
updateWfPagination(totalPages);
grid.innerHTML = "";
pageItems.forEach((wf) => {
filtered.forEach((wf) => {
const card = document.createElement("div");
card.className = "wfm-card";
card.dataset.filename = wf.filename;
const hasThumb = !!wf.thumbnail;
const badges = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join("");
const badges = wfBadgesHtml(wf);
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
const favStar = wf.metadata.favorite ? "\u2605" : "\u2606";
const favClass = wf.metadata.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
@@ -371,22 +377,6 @@ function renderGrid() {
}
}
function updateWfPagination(totalPages) {
const container = document.getElementById("wfm-workflow-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${state.currentPage + 1} / ${totalPages}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
});
}
function renderTableView(grid, filtered) {
const table = document.createElement("table");
@@ -407,7 +397,7 @@ function renderTableView(grid, filtered) {
const tr = document.createElement("tr");
tr.className = "wfm-table-row";
tr.dataset.filename = wf.filename;
const badges = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join("");
const badges = wfBadgesHtml(wf);
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
const thumbHtml = wf.thumbnail
? `<img src="${wf.thumbnail}" class="wfm-table-thumb" loading="lazy" />`
@@ -473,7 +463,6 @@ async function showSidePanel(wf, cardEl) {
titleEl.textContent = wf.filename.replace(/\.json$/, "");
contentEl.textContent = t("loading");
panel.style.display = "flex";
state.selectedWf = wf;
@@ -493,7 +482,7 @@ async function showSidePanel(wf, cardEl) {
sidePanelThumbUpdate(wf);
// Update group tab
sidePanelGroupUpdate();
renderSideGroup(wf);
const badgeEl = document.getElementById("wfm-json-format-badge");
try {
@@ -517,17 +506,17 @@ async function showSidePanel(wf, cardEl) {
}
function closeSidePanel() {
const panel = document.getElementById("wfm-side-panel");
if (panel) panel.style.display = "none";
document.querySelectorAll(".wfm-card, .wfm-table-row").forEach((c) =>
c.classList.remove("wfm-card-selected")
);
state.selectedWf = null;
const titleEl = document.getElementById("wfm-side-panel-title");
if (titleEl) titleEl.textContent = "";
const listLoadBtn = document.getElementById("wfm-list-load-btn");
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
sidePanelGroupUpdate();
renderSideGroup(null);
}
// ============================================
@@ -550,7 +539,7 @@ function sidePanelThumbUpdate(wf) {
nameEl.textContent = wf ? wf.filename.replace(/\.json$/, "") : "";
}
if (metaEl && wf) {
const badges = (wf.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
const badges = wfBadgesHtml(wf);
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
metaEl.innerHTML = `${badges} <span style="margin-left:4px;">${io}</span>`;
} else if (metaEl) {
@@ -562,60 +551,166 @@ function sidePanelThumbUpdate(wf) {
// Group Tab in Side Panel
// ============================================
function sidePanelGroupUpdate() {
const nameEl = document.getElementById("wfm-grp-prop-name");
const groupEl = document.getElementById("wfm-grp-prop-group");
const assignBtn = document.getElementById("wfm-assign-group-btn");
const removeBtn = document.getElementById("wfm-remove-group-btn");
function renderSideGroup(wf) {
const el = document.getElementById("wfm-side-tab-group");
if (!el) return;
if (!state.selectedWf) {
if (nameEl) nameEl.textContent = t("noSelection");
if (groupEl) groupEl.textContent = "";
if (assignBtn) assignBtn.disabled = true;
if (removeBtn) removeBtn.disabled = true;
const allGroups = groups.groupNames();
if (!wf) {
el.innerHTML = `<p style="padding:12px;color:var(--wfm-text-secondary);font-size:12px;">${t("noSelection")}</p>`;
_refreshGroupFilter();
return;
}
const filename = state.selectedWf.filename;
if (nameEl) nameEl.textContent = filename.replace(/\.json$/, "");
const grps = groups.groupsOf(filename);
if (groupEl) groupEl.textContent = grps.length > 0 ? `${t("groupsLabel")}: ${grps.join(", ")}` : `${t("groupsLabel")}: ${t("groupsNone")}`;
if (assignBtn) assignBtn.disabled = false;
const filename = wf.filename;
const memberOf = groups.groupsOf(filename);
const available = allGroups.filter(g => !memberOf.includes(g));
const selGroup = document.getElementById("wfm-group-select")?.value;
if (removeBtn) removeBtn.disabled = !selGroup || !grps.includes(selGroup);
}
el.innerHTML = `
<div style="padding:8px;">
<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:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-wf-grp-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`
).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-grp-assign-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${available.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-grp-assign-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div style="margin-bottom:12px;">
<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-grp-new-input" class="wfm-input"
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
<button id="wfm-grp-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
</div>
</div>
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-grp-manage-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-grp-rename-btn" class="wfm-btn wfm-btn-sm"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">&#9998;</button>
<button id="wfm-grp-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">&times;</button>
</div>
</div>
</div>
`;
function refreshGroupSelects() {
const sideSel = document.getElementById("wfm-group-select");
const filterSel = document.getElementById("wfm-group-filter");
const grpNames = groups.groupNames();
[filterSel, sideSel].forEach((sel, i) => {
if (!sel) return;
const prevVal = sel.value;
sel.innerHTML = i === 0
? `<option value="">${t("allGroups")}</option>`
: `<option value="">${t("selectGroup")}</option>`;
grpNames.forEach((g) => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
sel.appendChild(opt);
// グループから除外
el.querySelectorAll(".wfm-wf-grp-remove").forEach(btn => {
btn.addEventListener("click", () => {
groups.remove(filename, btn.dataset.group);
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Removed from "${btn.dataset.group}"`, "success");
});
if (grpNames.includes(prevVal)) sel.value = prevVal;
});
// グループに追加
el.querySelector("#wfm-grp-assign-btn")?.addEventListener("click", () => {
const g = el.querySelector("#wfm-grp-assign-sel")?.value;
if (!g) return;
groups.assign(filename, g);
renderSideGroup(wf);
showToast(`Added to "${g}"`, "success");
});
// グループ作成(作成後に現在のWFに追加)
el.querySelector("#wfm-grp-create-btn")?.addEventListener("click", () => {
const input = el.querySelector("#wfm-grp-new-input");
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "warning");
return;
}
groups.assign(filename, name);
input.value = "";
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" created`, "success");
});
// グループ名変更
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!oldName) return;
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
return;
}
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Renamed to "${newName}"`, "success");
});
// グループ削除
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
if (state.groupFilter === name) {
state.groupFilter = "";
const filterSel = document.getElementById("wfm-group-filter");
if (filterSel) filterSel.value = "";
}
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" deleted`, "success");
});
_refreshGroupFilter();
}
/** ツールバーのグループフィルタセレクトのみ更新 */
function _refreshGroupFilter() {
const filterSel = document.getElementById("wfm-group-filter");
if (!filterSel) return;
const prevVal = filterSel.value;
const grpNames = groups.groupNames();
filterSel.innerHTML = `<option value="">${t("allGroups")}</option>`;
grpNames.forEach(g => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
filterSel.appendChild(opt);
});
if (grpNames.includes(prevVal)) filterSel.value = prevVal;
}
// ============================================
// Badge Color Settings
// View Settings Panel
// ============================================
function renderBadgeSettings() {
function renderViewSettings() {
const panel = document.getElementById("wfm-badge-settings-panel");
if (!panel) return;
// === Default View section ===
const currentView = localStorage.getItem("wfm_default_view") || "thumb";
const viewOptions = [
{ value: "thumb", label: t("viewThumbnail") },
@@ -629,57 +724,20 @@ function renderBadgeSettings() {
</label>`
).join("");
// === Badge Colors section ===
const types = getAllModelTypes();
const allKnown = new Set([...Object.keys(BADGE_DEFAULT_COLORS), ...types]);
const rows = [...allKnown].sort().map((type) => {
const color = state.badgeColors[type] || BADGE_DEFAULT_COLORS[type] || "#0077ff";
return `<div class="wfm-badge-color-row" data-type="${type}">
<span class="wfm-badge wfm-badge-model" style="background:${color}">${type}</span>
<input type="color" class="wfm-badge-color-input" value="${color}" data-type="${type}" title="Change color for ${type}" />
</div>`;
}).join("");
panel.innerHTML = `
<div class="wfm-badge-settings-title">${t("defaultView")}</div>
<div class="wfm-settings-radio-group">${viewRadios}</div>
<hr style="border:none;border-top:1px solid var(--wfm-border);margin:8px 0;">
<div class="wfm-badge-settings-title">${t("badgeColors")}</div>
${rows}
<button id="wfm-badge-color-reset" class="wfm-btn wfm-btn-sm" style="margin-top:6px;width:100%;">${t("resetColors")}</button>`;
<div class="wfm-settings-radio-group">${viewRadios}</div>`;
// Default view change
panel.querySelectorAll('input[name="wfm-default-view"]').forEach((radio) => {
radio.addEventListener("change", () => {
localStorage.setItem("wfm_default_view", radio.value);
state.viewMode = radio.value;
// Update active state on view mode buttons
document.querySelectorAll(".wfm-view-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.view === radio.value);
});
renderGrid();
});
});
// Badge color change
panel.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
input.addEventListener("input", (e) => {
const type = e.target.dataset.type;
const color = e.target.value;
state.badgeColors[type] = color;
localStorage.setItem("wfm_badge_colors", JSON.stringify(state.badgeColors));
const badge = e.target.closest(".wfm-badge-color-row")?.querySelector(".wfm-badge");
if (badge) badge.style.background = color;
renderGrid();
});
});
panel.querySelector("#wfm-badge-color-reset")?.addEventListener("click", () => {
state.badgeColors = {};
localStorage.removeItem("wfm_badge_colors");
renderBadgeSettings();
renderGrid();
});
}
// ============================================
@@ -704,10 +762,21 @@ async function saveModalMeta(wf) {
function openDetailModal(wf) {
const meta = wf.metadata;
const types = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join(" ");
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
const overrideText = (meta.modelTypesOverride || []).join(", ");
const isFav = !!meta.favorite;
const palette = getBadgePalette();
const allBadgeLabels = Object.keys(palette).sort();
const activeBadges = meta.badges || [];
const badgeCheckboxes = allBadgeLabels.length === 0
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
: allBadgeLabels.map((label) => {
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
const checked = activeBadges.includes(label) ? " checked" : "";
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}>${escapeHtml(label)}</span>
</label>`;
}).join("");
const thumbSrc = wf.thumbnail || "";
const html = `
@@ -720,23 +789,9 @@ function openDetailModal(wf) {
<div class="wfm-modal-two-col">
<div class="wfm-modal-left">
<section>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<h4>${t("analysis")}</h4>
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-analyze">${t("analyze")}</button>
</div>
<div id="wfm-detail-analysis" class="wfm-detail-analysis">
<div>${types || `<span style="color:var(--wfm-text-secondary)">${t("noModelDetected")}</span>`}</div>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;">${io}</div>
</div>
<div id="wfm-detail-override-row" style="margin-top:8px;">
<div style="display:flex;gap:4px;align-items:center;">
<span style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">${t("modelCategory")}</span>
<input type="text" class="wfm-input" id="wfm-detail-model-override"
value="${overrideText}"
placeholder="${t("modelOverridePlaceholder")}" style="flex:1;font-size:12px;padding:4px 6px;min-width:0;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-detail-override-save">${t("save")}</button>
</div>
</div>
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-detail-badge-manage">&#9881; ${t("badgeManage")}</button></h4>
<div id="wfm-detail-badge-checkboxes" class="wfm-badge-checkboxes">${badgeCheckboxes}</div>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-detail-badges-save" style="margin-top:6px;">${t("save")}</button>
</section>
<section>
<h4>${t("tags")} <span style="font-weight:normal;font-size:11px;">${t("tagsHint")}</span></h4>
@@ -844,51 +899,40 @@ function openDetailModal(wf) {
document.getElementById(id)?.addEventListener("blur", () => saveModalMeta(wf));
});
// Analyze button
document.getElementById("wfm-detail-analyze")?.addEventListener("click", async () => {
const btn = document.getElementById("wfm-detail-analyze");
btn.disabled = true;
btn.textContent = t("analyzing");
try {
const res = await fetch("/api/wfm/workflows/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: wf.filename }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
wf.analysis = data.analysis;
const cached = state.workflows.find((w) => w.filename === wf.filename);
if (cached) cached.analysis = data.analysis;
// Re-render analysis section
const analysisEl = document.getElementById("wfm-detail-analysis");
if (analysisEl) {
const newTypes = (data.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
const newIo = `P:${data.analysis.inputs?.prompts || 0} I:${data.analysis.inputs?.images || 0} → ${data.analysis.outputs?.images || 0}`;
analysisEl.innerHTML = `<div>${newTypes || `<span style="color:var(--wfm-text-secondary)">${t("noModelDetected")}</span>`}</div>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;">${newIo}</div>`;
}
renderGrid();
showToast(t("analysisComplete"), "success");
} catch (err) {
showToast(t("analyzeError") + ": " + err.message, "error");
} finally {
btn.disabled = false;
btn.textContent = t("analyze");
}
// Badge manage button
document.getElementById("wfm-detail-badge-manage")?.addEventListener("click", () => {
openBadgeEditModal(() => {
// Rebuild checkboxes after palette changes
const palette = getBadgePalette();
const allLabels = Object.keys(palette).sort();
const currentBadges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map(cb => cb.value);
const el = document.getElementById("wfm-detail-badge-checkboxes");
if (!el) return;
el.innerHTML = allLabels.length === 0
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
: allLabels.map((label) => {
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
const checked = currentBadges.includes(label) ? " checked" : "";
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}>${escapeHtml(label)}</span>
</label>`;
}).join("");
});
});
// Model type override save
document.getElementById("wfm-detail-override-save")?.addEventListener("click", async () => {
const overrideStr = document.getElementById("wfm-detail-model-override")?.value || "";
const modelTypesOverride = overrideStr.split(",").map((t) => t.trim()).filter(Boolean);
// Badge save
document.getElementById("wfm-detail-badges-save")?.addEventListener("click", async () => {
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map(cb => cb.value);
try {
await saveMetadata(wf.filename, { modelTypesOverride });
wf.metadata.modelTypesOverride = modelTypesOverride;
await saveMetadata(wf.filename, { badges });
wf.metadata.badges = badges;
const cached = state.workflows.find((w) => w.filename === wf.filename);
if (cached) cached.metadata.modelTypesOverride = modelTypesOverride;
if (cached) cached.metadata.badges = badges;
renderGrid();
showToast(t("overrideSaved"), "success");
renderModelFilters();
showToast(t("modelsSaved"), "success");
} catch (err) {
showToast(t("saveError") + ": " + err.message, "error");
}
@@ -1129,9 +1173,6 @@ export function initWorkflowTab() {
});
}
// Side panel: close button
document.getElementById("wfm-side-panel-close")?.addEventListener("click", closeSidePanel);
// Side panel: tab switching
document.querySelectorAll(".wfm-side-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -1165,74 +1206,7 @@ export function initWorkflowTab() {
// Group management
groups.load();
refreshGroupSelects();
// Add group
document.getElementById("wfm-group-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-group-name-input");
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "error");
return;
}
input.value = "";
refreshGroupSelects();
showToast(`Group "${name}" created`, "success");
});
// Rename group
document.getElementById("wfm-group-rename-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const oldName = sel?.value;
if (!oldName) return;
const newName = prompt("New group name:", oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
return;
}
refreshGroupSelects();
sidePanelGroupUpdate();
showToast(`Group renamed to "${newName}"`, "success");
});
// Delete group
document.getElementById("wfm-group-delete-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const name = sel?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
refreshGroupSelects();
sidePanelGroupUpdate();
showToast(`Group "${name}" deleted`, "success");
});
// Assign to group
document.getElementById("wfm-assign-group-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const groupName = sel?.value;
if (!groupName || !state.selectedWf) return;
groups.assign(state.selectedWf.filename, groupName);
sidePanelGroupUpdate();
showToast(`Added to "${groupName}"`, "success");
});
// Remove from group
document.getElementById("wfm-remove-group-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const groupName = sel?.value;
if (!groupName || !state.selectedWf) return;
groups.remove(state.selectedWf.filename, groupName);
sidePanelGroupUpdate();
showToast(`Removed from "${groupName}"`, "success");
});
// Update remove button state when group select changes
document.getElementById("wfm-group-select")?.addEventListener("change", () => {
sidePanelGroupUpdate();
});
_refreshGroupFilter();
// Toolbar: group filter dropdown
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
@@ -1271,7 +1245,7 @@ export function initWorkflowTab() {
showToast("Refreshed", "success");
});
// Toolbar: Badge color settings
// Toolbar: ⚙ settings button (view settings panel)
const badgeSettingsBtn = document.getElementById("wfm-badge-settings-btn");
const badgeSettingsPanel = document.getElementById("wfm-badge-settings-panel");
if (badgeSettingsBtn && badgeSettingsPanel) {
@@ -1282,7 +1256,7 @@ export function initWorkflowTab() {
badgeSettingsPanel.style.display = "none";
return;
}
renderBadgeSettings();
renderViewSettings();
badgeSettingsPanel.style.display = "";
});
document.addEventListener("click", (e) => {
@@ -1292,15 +1266,9 @@ export function initWorkflowTab() {
});
}
// Toolbar: Reanalyze All button
document.getElementById("wfm-reanalyze-btn")?.addEventListener("click", async () => {
try {
const result = await reanalyzeAll();
showToast(`Reanalyzed ${result.count ?? 0} workflows`, "success");
await loadWorkflows();
} catch (err) {
showToast("Reanalyze error: " + err.message, "error");
}
// Toolbar: ⚙ Badge button
document.getElementById("wfm-badge-btn")?.addEventListener("click", () => {
openBadgeEditModal(() => renderGrid());
});
// Initial load
+761 -139
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+818
View File
@@ -0,0 +1,818 @@
/**
* Node Library Sidebar Panel
*
* Fixed sidebar panel injected into ComfyUI DOM.
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
* Items are draggable onto the canvas or click-to-place.
*/
console.log("[WFM] node_sets_menu.js loading...");
import { app } from "../../scripts/app.js";
console.log("[WFM] node_sets_menu.js: app imported");
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
// ============================================
// State
// ============================================
const state = {
visible: false,
activeTab: "favorites", // favorites | sets | groups
favorites: [], // [{name, display_name}]
nodeSets: [],
groups: {}, // {groupName: [nodeName, ...]}
metadata: {},
searchText: "",
loaded: false,
};
// ============================================
// API
// ============================================
const fetchNodeSets = async () => {
try {
const res = await fetch("/api/wfm/node-sets");
return res.ok ? await res.json() : [];
} catch { return []; }
};
const fetchMetadata = async () => {
try {
const res = await fetch("/api/wfm/nodes/metadata");
return res.ok ? await res.json() : {};
} catch { return {}; }
};
const fetchGroups = async () => {
try {
const res = await fetch("/api/wfm/nodes/groups");
return res.ok ? await res.json() : {};
} catch { return {}; }
};
const loadData = async () => {
const [metadata, nodeSets, groups] = await Promise.all([
fetchMetadata(), fetchNodeSets(), fetchGroups(),
]);
state.metadata = metadata;
state.nodeSets = nodeSets;
state.groups = groups;
// Extract favorites from metadata
state.favorites = [];
for (const [name, meta] of Object.entries(metadata)) {
if (name === "_groups") continue;
if (meta.favorite) {
state.favorites.push({ name, display_name: name });
}
}
state.loaded = true;
};
// ============================================
// Canvas placement helpers
// ============================================
const getCanvasDropPos = (e) => {
const canvas = app.canvas;
if (!canvas) return [100, 100];
const rect = canvas.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
return [x, y];
};
const placeSingleNode = (classType, pos) => {
const graph = app.graph;
if (!graph) return;
const node = LiteGraph.createNode(classType);
if (!node) {
showToast(`Unknown node: ${classType}`, "error");
return;
}
node.pos = pos || [100, 100];
graph.add(node);
app.canvas.setDirty(true, true);
showToast(`Placed: ${node.title || classType}`, "success");
};
const placeNodeSet = (nodeSet, pos) => {
const graph = app.graph;
const canvas = app.canvas;
if (!graph || !canvas) return;
const nodes = nodeSet.nodes || [];
if (nodes.length === 0) return;
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
const positions = nodes.map(n => n.rel_pos || [0, 0]);
const minX = Math.min(...positions.map(p => p[0]));
const minY = Math.min(...positions.map(p => p[1]));
const createdNodes = [];
for (const nodeDef of nodes) {
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
const node = LiteGraph.createNode(nodeDef.class_type);
if (!node) { createdNodes.push(null); continue; }
const rp = nodeDef.rel_pos || [0, 0];
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
if (nodeDef.title) node.title = nodeDef.title;
if (nodeDef.widget_values && node.widgets) {
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
const w = node.widgets.find(ww => ww.name === k);
if (w) w.value = v;
}
}
graph.add(node);
createdNodes.push(node);
}
for (const link of (nodeSet.links || [])) {
const from = createdNodes[link.from_node];
const to = createdNodes[link.to_node];
if (from && to) {
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
}
}
canvas.setDirty(true, true);
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
};
// ============================================
// Drag & Drop onto canvas
// ============================================
let dropHandlerInstalled = false;
const installCanvasDropHandler = () => {
if (dropHandlerInstalled) return;
dropHandlerInstalled = true;
const canvasEl = document.getElementById("graph-canvas");
if (!canvasEl) return;
canvasEl.addEventListener("dragover", (e) => {
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
});
canvasEl.addEventListener("drop", (e) => {
const raw = e.dataTransfer.getData("application/x-wfm-node");
if (!raw) return;
e.preventDefault();
const data = JSON.parse(raw);
const pos = getCanvasDropPos(e);
if (data.type === "single") {
placeSingleNode(data.classType, pos);
} else if (data.type === "set") {
const set = state.nodeSets.find(s => s.id === data.setId);
if (set) placeNodeSet(set, pos);
}
});
};
// ============================================
// Sidebar Panel DOM
// ============================================
let panelEl = null;
const PANEL_ID = "wfm-node-library-panel";
const createPanel = () => {
if (panelEl) return panelEl;
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="wfm-nlp-header">
<span class="wfm-nlp-title">Node Library</span>
<button class="wfm-nlp-refresh" title="Refresh">&#8635;</button>
<button class="wfm-nlp-close" title="Close">&times;</button>
</div>
<div class="wfm-nlp-tabs">
<button class="wfm-nlp-tab active" data-tab="favorites">&#9733; Favorites</button>
<button class="wfm-nlp-tab" data-tab="sets">&#9776; Sets</button>
<button class="wfm-nlp-tab" data-tab="groups">&#128193; Groups</button>
</div>
<div class="wfm-nlp-search">
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
</div>
<div class="wfm-nlp-content"></div>
`;
document.body.appendChild(panel);
panelEl = panel;
// Tab switching
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
btn.addEventListener("click", () => {
state.activeTab = btn.dataset.tab;
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderContent();
});
});
// Close
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
// Search
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
state.searchText = e.target.value.toLowerCase().trim();
renderContent();
});
// Refresh
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
await loadData();
renderContent();
showToast("Node Library refreshed", "success");
});
injectStyles();
return panel;
};
// ============================================
// Render content
// ============================================
const renderContent = () => {
const content = panelEl?.querySelector(".wfm-nlp-content");
if (!content) return;
if (!state.loaded) {
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
return;
}
switch (state.activeTab) {
case "favorites": renderFavorites(content); break;
case "sets": renderSets(content); break;
case "groups": renderGroups(content); break;
}
};
const renderFavorites = (container) => {
let items = state.favorites;
if (state.searchText) {
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
}
if (items.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
</div>`;
return;
}
container.innerHTML = "";
for (const node of items) {
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
el.addEventListener("dblclick", () => {
placeSingleNode(node.name);
});
container.appendChild(el);
}
};
const renderSets = (container) => {
let items = state.nodeSets;
if (state.searchText) {
items = items.filter(s =>
s.name.toLowerCase().includes(state.searchText) ||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
);
}
if (items.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
</div>`;
return;
}
container.innerHTML = "";
for (const set of items) {
const count = (set.nodes || []).length;
const el = createDraggableItem(
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
"set",
{ setId: set.id },
set.description
);
el.addEventListener("dblclick", () => {
placeNodeSet(set);
});
container.appendChild(el);
}
};
const renderGroups = (container) => {
const groupNames = Object.keys(state.groups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
</div>`;
return;
}
let filtered = groupNames;
if (state.searchText) {
filtered = groupNames.filter(g =>
g.toLowerCase().includes(state.searchText) ||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
);
}
container.innerHTML = "";
for (const groupName of filtered) {
const nodes = state.groups[groupName] || [];
const section = document.createElement("div");
section.className = "wfm-nlp-group-section";
const header = document.createElement("div");
header.className = "wfm-nlp-group-header";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.length}</span>`;
header.addEventListener("click", () => {
const list = section.querySelector(".wfm-nlp-group-list");
list.style.display = list.style.display === "none" ? "block" : "none";
header.classList.toggle("collapsed");
});
section.appendChild(header);
const list = document.createElement("div");
list.className = "wfm-nlp-group-list";
for (const nodeName of nodes) {
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
list.appendChild(el);
}
section.appendChild(list);
container.appendChild(section);
}
};
// ============================================
// Draggable item factory
// ============================================
const createDraggableItem = (label, type, data, subtitle) => {
const el = document.createElement("div");
el.className = "wfm-nlp-item";
el.draggable = true;
el.innerHTML = `
<div class="wfm-nlp-item-label">${label}</div>
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
`;
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
el.classList.add("dragging");
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
});
return el;
};
// ============================================
// Toggle & exports
// ============================================
export const togglePanel = async () => {
const panel = createPanel();
state.visible = !state.visible;
panel.style.display = state.visible ? "flex" : "none";
if (state.visible && !state.loaded) {
await loadData();
renderContent();
}
installCanvasDropHandler();
};
export const getNodeSetsIcon = () => `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<path d="M17.5 14v7M14 17.5h7"/>
</svg>
`;
// ============================================
// Toast
// ============================================
const showToast = (message, type = "info") => {
const existing = document.getElementById("wfm-ns-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "wfm-ns-toast";
const colors = {
success: { bg: "rgba(46,213,115,0.95)" },
error: { bg: "rgba(255,71,87,0.95)" },
info: { bg: "rgba(74,158,255,0.95)" },
};
Object.assign(toast.style, {
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
});
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
};
const esc = (s) => s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") : "";
// ============================================
// Save selected nodes as Node Set (context menu)
// ============================================
const saveSelectedAsNodeSet = async () => {
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
if (selectedNodes.length === 0) {
showToast("Select at least one node first", "error");
return;
}
// Prompt for name
const name = prompt("Node Set name:");
if (!name || !name.trim()) return;
// Build node list with relative positions
// Use the top-left of the bounding box as origin
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
const nodeIdToIndex = new Map();
const nodes = [];
selectedNodes.forEach((node, i) => {
nodeIdToIndex.set(node.id, i);
nodes.push({
class_type: node.type || node.comfyClass || "",
title: node.title || "",
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
});
});
// Extract links between selected nodes
const links = [];
const graph = app.graph;
for (const node of selectedNodes) {
if (!node.inputs) continue;
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
const input = node.inputs[slotIdx];
if (input.link == null) continue;
// Resolve link data
let linkData = null;
if (graph.links instanceof Map) {
linkData = graph.links.get(input.link);
} else if (graph.links) {
linkData = graph.links[input.link];
}
if (!linkData) continue;
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
// or object with .origin_id, .origin_slot, etc.
const originId = linkData.origin_id ?? linkData[1];
const originSlot = linkData.origin_slot ?? linkData[2];
const targetSlot = linkData.target_slot ?? linkData[4];
// Only include if both source and target are in the selection
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
links.push({
from_node: nodeIdToIndex.get(originId),
from_slot: originSlot,
to_node: nodeIdToIndex.get(node.id),
to_slot: targetSlot,
});
}
}
}
// Save via API
try {
const res = await fetch("/api/wfm/node-sets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
});
const result = await res.json();
if (result.status === "ok") {
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
// Refresh sidebar if open
if (state.loaded) {
await loadData();
renderContent();
}
} else {
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
}
} catch (err) {
showToast("Failed to save: " + err.message, "error");
}
};
// Register context menu item on all nodes
app.registerExtension({
name: "WorkflowStudio.NodeSetContextMenu",
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
// Add separator + menu item
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
});
// ============================================
// Styles
// ============================================
const injectStyles = () => {
if (document.getElementById("wfm-nlp-styles")) return;
const style = document.createElement("style");
style.id = "wfm-nlp-styles";
style.textContent = `
#${PANEL_ID} {
position: fixed;
right: 0;
top: 0;
width: 280px;
height: 100vh;
background: var(--comfy-menu-bg, #1e1e1e);
border-left: 1px solid var(--border-color, #4e4e4e);
z-index: 9999;
display: none;
flex-direction: column;
font-family: sans-serif;
font-size: 13px;
color: var(--input-text, #ddd);
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
}
.wfm-nlp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-title {
font-weight: 600;
font-size: 14px;
flex: 1;
}
.wfm-nlp-close {
background: none;
border: none;
color: var(--input-text, #ddd);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
opacity: 0.6;
}
.wfm-nlp-close:hover { opacity: 1; }
.wfm-nlp-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-tab {
flex: 1;
padding: 8px 4px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--input-text, #ddd);
font-size: 11px;
cursor: pointer;
opacity: 0.6;
transition: all 0.15s;
white-space: nowrap;
}
.wfm-nlp-tab:hover { opacity: 0.9; }
.wfm-nlp-tab.active {
opacity: 1;
border-bottom-color: var(--p-button-background, #4a9eff);
}
.wfm-nlp-search {
padding: 8px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-search-input {
width: 100%;
padding: 6px 8px;
background: var(--comfy-input-bg, #2a2a2a);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
color: var(--input-text, #ddd);
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.wfm-nlp-search-input:focus {
border-color: var(--p-button-background, #4a9eff);
}
.wfm-nlp-content {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.wfm-nlp-empty {
padding: 24px 16px;
text-align: center;
color: var(--descrip-text, #999);
font-size: 12px;
line-height: 1.6;
}
.wfm-nlp-item {
padding: 7px 12px;
cursor: grab;
border-bottom: 1px solid var(--border-color, #3a3a3a);
transition: background 0.12s;
user-select: none;
}
.wfm-nlp-item:hover {
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-item.dragging {
opacity: 0.5;
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-item-label {
font-size: 12px;
line-height: 1.3;
}
.wfm-nlp-item-sub {
font-size: 10px;
color: var(--descrip-text, #888);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wfm-nlp-badge {
display: inline-block;
font-size: 10px;
padding: 1px 5px;
background: var(--comfy-input-bg, #444);
border-radius: 3px;
color: var(--descrip-text, #aaa);
margin-left: 4px;
vertical-align: middle;
}
.wfm-nlp-group-section {
border-bottom: 1px solid var(--border-color, #3a3a3a);
}
.wfm-nlp-group-header {
padding: 8px 12px;
cursor: pointer;
font-weight: 500;
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.12s;
}
.wfm-nlp-group-header:hover {
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-group-header::before {
content: "\\25BC";
font-size: 8px;
margin-right: 6px;
transition: transform 0.15s;
}
.wfm-nlp-group-header.collapsed::before {
transform: rotate(-90deg);
}
.wfm-nlp-group-list {
padding-left: 8px;
}
.wfm-nlp-group-list .wfm-nlp-item {
padding: 5px 12px;
font-size: 11px;
}
.wfm-nlp-refresh {
background: none;
border: none;
color: var(--input-text, #ddd);
font-size: 16px;
cursor: pointer;
opacity: 0.7;
}
.wfm-nlp-refresh:hover { opacity: 1; }
`;
document.head.appendChild(style);
};
// ============================================
// Context Menu — Save selected nodes as Node Set
// ============================================
const saveSelectedAsNodeSet = async () => {
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
if (selectedNodes.length === 0) {
showToast("Select at least one node first", "error");
return;
}
const name = prompt("Node Set name:");
if (!name || !name.trim()) return;
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
const nodeIdToIndex = new Map();
const nodes = [];
selectedNodes.forEach((node, i) => {
nodeIdToIndex.set(node.id, i);
nodes.push({
class_type: node.type || node.comfyClass || "",
title: node.title || "",
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
});
});
const links = [];
const graph = app.graph;
for (const node of selectedNodes) {
if (!node.inputs) continue;
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
const input = node.inputs[slotIdx];
if (input.link == null) continue;
let linkData = null;
if (graph.links instanceof Map) {
linkData = graph.links.get(input.link);
} else if (graph.links) {
linkData = graph.links[input.link];
}
if (!linkData) continue;
const originId = linkData.origin_id ?? linkData[1];
const originSlot = linkData.origin_slot ?? linkData[2];
const targetSlot = linkData.target_slot ?? linkData[4];
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
links.push({
from_node: nodeIdToIndex.get(originId),
from_slot: originSlot,
to_node: nodeIdToIndex.get(node.id),
to_slot: targetSlot,
});
}
}
}
try {
const res = await fetch("/api/wfm/node-sets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
});
const result = await res.json();
if (result.status === "ok") {
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
if (state.loaded) { await loadData(); renderContent(); }
} else {
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
}
} catch (err) {
showToast("Failed to save: " + err.message, "error");
}
};
export { saveSelectedAsNodeSet };
+16 -33
View File
@@ -605,50 +605,33 @@ app.registerExtension({
};
injectStyles();
const applyButtonIcon = (button, className, getIconFn, extraStyles) => {
if (button.querySelector("svg")) return;
button.classList.add(className);
button.innerHTML = getIconFn();
button.style.borderRadius = "4px";
button.style.padding = "6px";
if (extraStyles) Object.assign(button.style, extraStyles);
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
};
const buttonConfigs = [
{ tooltip: BUTTON_TOOLTIP, className: "wfm-top-menu-button", getIcon: getWfmIcon, styles: { backgroundColor: "var(--primary-bg)" } },
{ tooltip: SNAPSHOT_TOOLTIP, className: "wfm-snapshot-button", getIcon: getSnapshotIcon, styles: null },
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
];
const replaceAllIcons = () => {
const replaceButtonIcons = () => {
let foundAny = false;
for (const cfg of buttonConfigs) {
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
applyButtonIcon(btn, cfg.className, cfg.getIcon, cfg.styles);
foundAny = true;
btn.classList.add(cfg.className);
btn.innerHTML = cfg.getIcon();
btn.style.borderRadius = "4px";
btn.style.padding = "6px";
if (cfg.styles) Object.assign(btn.style, cfg.styles);
const svg = btn.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
}
};
const waitAndObserve = () => {
const firstBtn = document.querySelector(`button[aria-label="${BUTTON_TOOLTIP}"]`);
if (!firstBtn) {
requestAnimationFrame(waitAndObserve);
return;
}
replaceAllIcons();
const container = firstBtn.closest(".actionbar-container") || firstBtn.parentElement;
if (container) {
new MutationObserver(() => replaceAllIcons()).observe(container, {
childList: true,
subtree: true,
});
if (!foundAny) {
requestAnimationFrame(replaceButtonIcons);
}
};
requestAnimationFrame(waitAndObserve);
requestAnimationFrame(replaceButtonIcons);
},
});
+565
View File
@@ -0,0 +1,565 @@
console.log("[WFM] top_menu_extension.js loading...");
import { app } from "../../scripts/app.js";
console.log("[WFM] app imported:", !!app);
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
console.log("[WFM] node_sets_menu imports loaded");
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
const WFM_PATH = "/wfm";
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
const MAX_ATTACH_ATTEMPTS = 120;
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const openWorkflowStudio = (event) => {
const url = `${window.location.origin}${WFM_PATH}`;
if (event.shiftKey) {
window.open(url, "_blank", NEW_WINDOW_FEATURES);
return;
}
window.open(url, "_blank");
};
// ============================================
// Canvas Snapshot - Export workflow as PNG thumbnail
// ============================================
const getWorkflowFilename = () => {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
return `wf_${stamp}.json`;
};
// ---- PNG tEXt chunk embedding helpers ----
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
const joinArrayBuffer = (...bufs) => {
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
bufs.reduce((offset, buf) => {
result.set(buf, offset);
return offset + buf.byteLength;
}, 0);
return result;
};
let _crcTable = null;
const crc32 = (data) => {
if (!_crcTable) {
_crcTable = [];
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;
}
_crcTable[n] = c;
}
}
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) {
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
};
const embedWorkflowInPng = async (blob, workflowJson) => {
const buffer = await blob.arrayBuffer();
const typedArr = new Uint8Array(buffer);
const view = new DataView(buffer);
// Create tEXt chunk: "tEXtworkflow\0{json}"
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
const ihdrDataLen = view.getUint32(8);
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
return new Blob([result], { type: "image/png" });
};
// ---- Canvas capture ----
const captureCanvasSnapshot = () => {
return new Promise((resolve, reject) => {
try {
const graph = app.graph;
const canvas = app.canvas;
if (!graph || !canvas || !graph._nodes?.length) {
reject(new Error("No workflow loaded"));
return;
}
// Serialize workflow data before changing canvas state
const workflowJson = JSON.stringify(graph.serialize());
// Calculate bounds of all nodes
const bounds = graph._nodes.reduce(
(p, n) => {
if (n.pos[0] < p[0]) p[0] = n.pos[0];
if (n.pos[1] < p[1]) p[1] = n.pos[1];
const b = n.getBounding();
const r = n.pos[0] + b[2];
const bot = n.pos[1] + b[3];
if (r > p[2]) p[2] = r;
if (bot > p[3]) p[3] = bot;
return p;
},
[99999, 99999, -99999, -99999]
);
// Add padding
const padding = 80;
bounds[0] -= padding;
bounds[1] -= padding;
bounds[2] += padding;
bounds[3] += padding;
// Save current canvas state
const savedState = {
scale: canvas.ds.scale,
width: canvas.canvas.width,
height: canvas.canvas.height,
offset: [...canvas.ds.offset],
transform: canvas.canvas.getContext("2d").getTransform(),
};
// Set canvas to render full workflow
const dpr = window.devicePixelRatio || 1;
canvas.ds.scale = 1;
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
canvas.ds.offset = [-bounds[0], -bounds[1]];
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
// Render
canvas.draw(true, true);
// Capture to blob
canvas.canvas.toBlob(async (rawBlob) => {
// Restore canvas state
canvas.ds.scale = savedState.scale;
canvas.canvas.width = savedState.width;
canvas.canvas.height = savedState.height;
canvas.ds.offset = savedState.offset;
canvas.canvas.getContext("2d").setTransform(savedState.transform);
canvas.draw(true, true);
if (!rawBlob) {
reject(new Error("Failed to generate canvas image"));
return;
}
try {
// Embed workflow JSON into PNG tEXt chunk
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
resolve(blob);
} catch (embedErr) {
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
resolve(rawBlob);
}
}, "image/png");
} catch (err) {
reject(err);
}
});
};
const saveCanvasToWorkflowStudio = async () => {
const filename = getWorkflowFilename();
try {
showNotification("Capturing workflow image...", "info");
const blob = await captureCanvasSnapshot();
const fd = new FormData();
fd.append("filename", filename);
fd.append("image", blob, "canvas.png");
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
method: "POST",
body: fd,
});
const data = await res.json();
if (data.error) {
throw new Error(data.error);
}
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
} catch (err) {
showNotification("Error: " + err.message, "error");
}
};
const showNotification = (message, type = "info") => {
// Try using ComfyUI's built-in toast if available
try {
if (app.ui?.dialog) {
// Fallback: use a simple temporary notification
}
} catch (e) { /* ignore */ }
// Simple toast notification
const existing = document.getElementById("wfm-snapshot-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "wfm-snapshot-toast";
const colors = {
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
};
const c = colors[type] || colors.info;
Object.assign(toast.style, {
position: "fixed",
bottom: "24px",
left: "50%",
transform: "translateX(-50%)",
padding: "10px 24px",
background: c.bg,
color: c.color,
borderRadius: "8px",
fontSize: "14px",
fontWeight: "500",
zIndex: "99999",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
transition: "opacity 0.3s ease",
whiteSpace: "nowrap",
});
toast.textContent = message;
document.body.appendChild(toast);
if (type !== "info") {
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, 3000);
} else {
// Info messages will be replaced by success/error
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
};
// ============================================
// Version Detection & UI
// ============================================
const getComfyUIFrontendVersion = async () => {
try {
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
return window["__COMFYUI_FRONTEND_VERSION__"];
}
} catch (error) {
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
}
try {
const response = await fetch("/system_stats");
const data = await response.json();
if (data?.system?.comfyui_frontend_version) {
return data.system.comfyui_frontend_version;
}
if (data?.system?.required_frontend_version) {
return data.system.required_frontend_version;
}
} catch (error) {
console.warn("Workflow Studio: unable to fetch system_stats:", error);
}
return "0.0.0";
};
const parseVersion = (versionStr) => {
if (!versionStr || typeof versionStr !== "string") {
return [0, 0, 0];
}
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts;
};
const compareVersions = (version1, version2) => {
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1;
if (v1[i] < v2[i]) return -1;
}
return 0;
};
const supportsActionBarButtons = async () => {
const version = await getComfyUIFrontendVersion();
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
};
const getWfmIcon = () => {
return `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
</svg>
`;
};
const getSnapshotIcon = () => {
return `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M8 5 L9 3 L15 3 L16 5"/>
</svg>
`;
};
// ============================================
// Button Creation
// ============================================
const createTopMenuButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "wfm",
tooltip: BUTTON_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
});
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
button.element.title = BUTTON_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getWfmIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", openWorkflowStudio);
return button;
};
const createSnapshotButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "snapshot",
tooltip: SNAPSHOT_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse",
});
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
button.element.title = SNAPSHOT_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getSnapshotIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
return button;
};
const createNodeSetsButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "node-sets",
tooltip: NODE_SETS_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
});
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
button.element.title = NODE_SETS_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getNodeSetsIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", (e) => {
e.stopPropagation();
togglePanel();
});
return button;
};
const attachTopMenuButton = async (attempt = 0) => {
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
return;
}
// Try settingsGroup first (legacy ComfyUI)
const settingsGroup = app.menu?.settingsGroup;
// Fallback: find the legacy topbar container used by newer ComfyUI
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
if (attempt >= MAX_ATTACH_ATTEMPTS) {
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
return;
}
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
return;
}
const wfmButton = await createTopMenuButton();
const snapshotButton = await createSnapshotButton();
const nodeSetsButton = await createNodeSetsButton();
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
if (settingsGroup?.element?.parentElement) {
settingsGroup.element.before(buttonGroup.element);
} else if (legacyContainer) {
legacyContainer.prepend(buttonGroup.element);
}
};
app.registerExtension({
name: "WorkflowStudio.TopMenu",
actionBarButtons: [
{
icon: "icon-[mdi--file-document-multiple] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openWorkflowStudio,
},
{
icon: "icon-[mdi--camera] size-4",
tooltip: SNAPSHOT_TOOLTIP,
onClick: saveCanvasToWorkflowStudio,
},
{
icon: "icon-[mdi--view-grid-plus] size-4",
tooltip: NODE_SETS_TOOLTIP,
onClick: () => togglePanel(),
},
],
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
async setup() {
const injectStyles = () => {
const styleId = "wfm-top-menu-button-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
wfmButtons.forEach((button) => {
button.classList.add("wfm-top-menu-button");
button.innerHTML = getWfmIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
button.style.backgroundColor = "var(--primary-bg)";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
snapButtons.forEach((button) => {
button.classList.add("wfm-snapshot-button");
button.innerHTML = getSnapshotIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
nsButtons.forEach((button) => {
button.classList.add("wfm-node-sets-btn");
button.innerHTML = getNodeSetsIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
if (wfmButtons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
}
};
requestAnimationFrame(replaceButtonIcon);
},
});
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+599
View File
@@ -0,0 +1,599 @@
{
"id": "f11699d9-4b62-47bd-b501-e49d609d9810",
"revision": 0,
"last_node_id": 10,
"last_link_id": 10,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": [
315,
474
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
378124453179007,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": [
210,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 10
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": [
210,
270
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 9
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": [
315,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
51.3,
182.49999999999997
],
"size": [
315,
98
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": []
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
]
},
{
"id": 10,
"type": "VAELoader",
"pos": [
61.897062649999256,
347.8818042399999
],
"size": [
270,
58
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
10
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "VAELoader"
},
"widgets_values": [
"vae-ft-mse-840000-ema-pruned.safetensors"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"flower"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
10,
0,
8,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [
0,
0
],
"scale": 1
},
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+887
View File
@@ -0,0 +1,887 @@
{
"id": "831a2929-0536-4715-a530-72a8b91c00fd",
"revision": 0,
"last_node_id": 13,
"last_link_id": 21,
"nodes": [
{
"id": 12,
"type": "CLIPTextEncodeEditPlus",
"pos": [
1364.9859298350216,
-408.28982585532515
],
"size": [
300,
257
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"localized_name": "text1",
"name": "text1",
"type": "STRING",
"link": 18
},
{
"localized_name": "text2",
"name": "text2",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "text_edit",
"name": "text_edit",
"type": "STRING",
"widget": {
"name": "text_edit"
},
"link": null
},
{
"localized_name": "mode",
"name": "mode",
"type": "COMBO",
"widget": {
"name": "mode"
},
"link": null
}
],
"outputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"links": [
17
]
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"links": [
21
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "CLIPTextEncodeEditPlus"
},
"widgets_values": [
"blue flower",
"+af",
""
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
1742.6139298350226,
-576.3158258553248
],
"size": [
315,
474
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 15
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 5
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 6
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "KSampler"
},
"widgets_values": [
1018108390999488,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 4,
"type": "VAEDecode",
"pos": [
2086.446929835019,
-568.6398258553247
],
"size": [
210,
46
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 20
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
12
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
2326.33789983502,
-566.7928258553247
],
"size": [
210,
270
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 12
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 13,
"type": "ShowText|pysssss",
"pos": [
1363.0960298350246,
-578.3718258553248
],
"size": [
305.77,
111.08999999999992
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"link": 21
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"shape": 6,
"type": "STRING",
"links": null
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "ShowText|pysssss"
},
"widgets_values": [
"girl,sentai,sentai,flower petal,full body,long hair,long legs,white boots and white gloves,<lora:9_Toshikazu:0.4> Toshikazu,<lora:9_Iggym:0.3> Iggym, painterly, realistic, sketch, traditional media, \\(medium\\)\n<lora:9_Landuo941:0.3> Landuo941\n<lora:Manga122:0.2>,<lora:MayuMurataIllustrious:0.3> Mayu Murata\n<lora:9_Navybluesparrow:0.4> Navybluesparrow\n<lora:9_RiyokoIkeda:0.2> Riyoko Ikeda\n<lora:9_ShumiiberryAlt:0.2> Shumiiberry,\n<lora:Shuiyuan:0.2> 5huny4n_5ty13 \n<lora:test_mix:0.2>,,blue flower"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
992.022929835024,
79.15817414467442
],
"size": [
271.44000000000005,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 1,
"type": "ImageMetadataLoRALoader",
"pos": [
987.5885798350228,
-582.1963058553249
],
"size": [
286,
565
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "metadata",
"name": "metadata",
"shape": 7,
"type": "METADATA",
"link": 3
},
{
"localized_name": "lora_1",
"name": "lora_1",
"type": "COMBO",
"widget": {
"name": "lora_1"
},
"link": null
},
{
"localized_name": "strength_model_1",
"name": "strength_model_1",
"type": "FLOAT",
"widget": {
"name": "strength_model_1"
},
"link": null
},
{
"localized_name": "strength_clip_1",
"name": "strength_clip_1",
"type": "FLOAT",
"widget": {
"name": "strength_clip_1"
},
"link": null
},
{
"localized_name": "lora_2",
"name": "lora_2",
"type": "COMBO",
"widget": {
"name": "lora_2"
},
"link": null
},
{
"localized_name": "strength_model_2",
"name": "strength_model_2",
"type": "FLOAT",
"widget": {
"name": "strength_model_2"
},
"link": null
},
{
"localized_name": "strength_clip_2",
"name": "strength_clip_2",
"type": "FLOAT",
"widget": {
"name": "strength_clip_2"
},
"link": null
},
{
"localized_name": "lora_3",
"name": "lora_3",
"type": "COMBO",
"widget": {
"name": "lora_3"
},
"link": null
},
{
"localized_name": "strength_model_3",
"name": "strength_model_3",
"type": "FLOAT",
"widget": {
"name": "strength_model_3"
},
"link": null
},
{
"localized_name": "strength_clip_3",
"name": "strength_clip_3",
"type": "FLOAT",
"widget": {
"name": "strength_clip_3"
},
"link": null
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"links": [
15
]
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"links": [
14,
16
]
}
],
"properties": {
"Node name for S&R": "ImageMetadataLoRALoader",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"temp\\Manga122.safetensors",
0.2,
0.2,
"temp\\MayuMurataIllustrious.safetensors",
0.3,
0.3,
"temp\\Shuiyuan.safetensors",
0.2,
0.2,
""
]
},
{
"id": 2,
"type": "ImageMetadataPromptLoader",
"pos": [
626.8189298350237,
-586.5118258553252
],
"size": [
286,
709
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
},
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": null
},
{
"localized_name": "positive_text",
"name": "positive_text",
"type": "STRING",
"widget": {
"name": "positive_text"
},
"link": null
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"widget": {
"name": "negative_text"
},
"link": null
},
{
"localized_name": "_metadata_json",
"name": "_metadata_json",
"type": "STRING",
"widget": {
"name": "_metadata_json"
},
"link": null
}
],
"outputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"links": [
1
]
},
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"links": [
2
]
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"links": [
20
]
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "positive_text",
"name": "positive_text",
"type": "STRING",
"links": [
18
]
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"links": []
},
{
"localized_name": "metadata",
"name": "metadata",
"type": "METADATA",
"links": [
3
]
}
],
"properties": {
"Node name for S&R": "ImageMetadataPromptLoader",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"anime\\CHEYENNE_v24HyperIllustration.safetensors",
"SDXL-VAE-FP16-Fix.safetensors",
"girl,sentai,sentai,flower petal,full body,long hair,long legs,white boots and white gloves,<lora:9_Toshikazu:0.4> Toshikazu,<lora:9_Iggym:0.3> Iggym, painterly, realistic, sketch, traditional media, \\(medium\\)\n<lora:9_Landuo941:0.3> Landuo941\n<lora:Manga122:0.2>,<lora:MayuMurataIllustrious:0.3> Mayu Murata\n<lora:9_Navybluesparrow:0.4> Navybluesparrow\n<lora:9_RiyokoIkeda:0.2> Riyoko Ikeda\n<lora:9_ShumiiberryAlt:0.2> Shumiiberry,\n<lora:Shuiyuan:0.2> 5huny4n_5ty13 \n<lora:test_mix:0.2>,",
"helmet,mask",
"{\"loras\":[{\"name\":\"9_Toshikazu\",\"strength_model\":0.4,\"strength_clip\":0.4},{\"name\":\"9_Iggym\",\"strength_model\":0.3,\"strength_clip\":0.3},{\"name\":\"9_Landuo941\",\"strength_model\":0.3,\"strength_clip\":0.3},{\"name\":\"Manga122\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"MayuMurataIllustrious\",\"strength_model\":0.3,\"strength_clip\":0.3},{\"name\":\"9_Navybluesparrow\",\"strength_model\":0.4,\"strength_clip\":0.4},{\"name\":\"9_RiyokoIkeda\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"9_ShumiiberryAlt\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"Shuiyuan\",\"strength_model\":0.2,\"strength_clip\":0.2},{\"name\":\"test_mix\",\"strength_model\":0.2,\"strength_clip\":0.2}]}",
""
]
},
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [
1360.6159298350235,
-96.8748258553254
],
"size": [
301.4950151367186,
88
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
5
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
},
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
]
}
],
"links": [
[
1,
2,
0,
1,
0,
"MODEL"
],
[
2,
2,
1,
1,
1,
"CLIP"
],
[
3,
2,
7,
1,
2,
"METADATA"
],
[
5,
10,
0,
3,
2,
"CONDITIONING"
],
[
6,
5,
0,
3,
3,
"LATENT"
],
[
7,
3,
0,
4,
0,
"LATENT"
],
[
12,
4,
0,
9,
0,
"IMAGE"
],
[
14,
1,
1,
10,
0,
"CLIP"
],
[
15,
1,
0,
3,
0,
"MODEL"
],
[
16,
1,
1,
12,
0,
"CLIP"
],
[
17,
12,
0,
3,
1,
"CONDITIONING"
],
[
18,
2,
5,
12,
1,
"STRING"
],
[
20,
2,
2,
4,
1,
"VAE"
],
[
21,
12,
1,
13,
0,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

+719
View File
@@ -0,0 +1,719 @@
{
"id": "03047124-7d5d-4eb4-b458-558d6dec9b5e",
"revision": 0,
"last_node_id": 28,
"last_link_id": 50,
"nodes": [
{
"id": 8,
"type": "VAEEncode",
"pos": [
551.8454158175873,
-685.9812381649755
],
"size": [
242.296875,
96
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": 49
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 34
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "VAEEncode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": []
},
{
"id": 22,
"type": "VAELoader",
"pos": [
75.4452850000009,
-464.22850100000056
],
"size": [
270,
58
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
34,
35
]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"SDXL-VAE-FP16-Fix.safetensors"
]
},
{
"id": 5,
"type": "VAEDecode",
"pos": [
1312.282348239462,
-683.1137137997414
],
"size": [
254.390625,
96
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 11
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 35
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
50
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": []
},
{
"id": 7,
"type": "KSampler",
"pos": [
1000.0439480441502,
-674.780789922787
],
"size": [
270,
589.3125
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 36
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 37
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 10
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 5
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
11
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
240074286219855,
"randomize",
30,
8,
"euler",
"karras",
0.7
]
},
{
"id": 24,
"type": "CLIPTextEncode",
"pos": [
528.6156850000002,
-509.49460100000033
],
"size": [
400,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 38
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
37
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"flower,anime girl"
]
},
{
"id": 11,
"type": "CLIPTextEncode",
"pos": [
526.8870184488446,
-252.08752270664039
],
"size": [
400,
200
],
"flags": {
"collapsed": false
},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 9
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
10
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
""
]
},
{
"id": 9,
"type": "CheckpointLoaderSimple",
"pos": [
70.30033916275676,
-694.6312926669282
],
"size": [
270,
154.65625
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [
36
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
9,
38
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": []
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": [
"anime\\IllustriousStyle_v22.safetensors"
]
},
{
"id": 28,
"type": "ImageFeeder",
"pos": [
76.77559983502367,
-338.79391585532534
],
"size": [
253.923,
319.923
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "directory",
"name": "directory",
"type": "STRING",
"widget": {
"name": "directory"
},
"link": null
},
{
"localized_name": "sort_mode",
"name": "sort_mode",
"type": "COMBO",
"widget": {
"name": "sort_mode"
},
"link": null
},
{
"localized_name": "index",
"name": "index",
"type": "INT",
"widget": {
"name": "index"
},
"link": null
},
{
"localized_name": "start_index",
"name": "start_index",
"type": "INT",
"widget": {
"name": "start_index"
},
"link": null
},
{
"localized_name": "end_index",
"name": "end_index",
"type": "INT",
"widget": {
"name": "end_index"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "use_selection",
"name": "use_selection",
"type": "BOOLEAN",
"widget": {
"name": "use_selection"
},
"link": null
},
{
"localized_name": "selected_files",
"name": "selected_files",
"type": "STRING",
"widget": {
"name": "selected_files"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
49
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "ImageFeeder"
},
"widgets_values": [
"test",
"ascending",
0,
0,
0,
1,
908906407258686,
"randomize",
true,
"[\"00030-2656222951.png\",\"00063-2334831609.png\",\"02121-3711614362.png\"]",
""
]
},
{
"id": 4,
"type": "PreviewImage",
"pos": [
1603.3762031816507,
-693.7018206825535
],
"size": [
284.5062499999997,
301.77375000000023
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 50
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {},
"version": "7.8"
}
},
"widgets_values": []
}
],
"links": [
[
5,
8,
0,
7,
3,
"LATENT"
],
[
9,
9,
1,
11,
0,
"CLIP"
],
[
10,
11,
0,
7,
2,
"CONDITIONING"
],
[
11,
7,
0,
5,
0,
"LATENT"
],
[
34,
22,
0,
8,
1,
"VAE"
],
[
35,
22,
0,
5,
1,
"VAE"
],
[
36,
9,
0,
7,
0,
"MODEL"
],
[
37,
24,
0,
7,
1,
"CONDITIONING"
],
[
38,
9,
1,
24,
0,
"CLIP"
],
[
49,
28,
0,
8,
0,
"IMAGE"
],
[
50,
5,
0,
4,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ue_links": [],
"links_added_by_ue": [],
"linearData": {
"inputs": [
[
"9",
"ckpt_name"
],
[
"3",
"pose_editor_3d_widget"
],
[
"3",
"timer_interval"
]
],
"outputs": []
}
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

+599
View File
@@ -0,0 +1,599 @@
{
"id": "f11699d9-4b62-47bd-b501-e49d609d9810",
"revision": 0,
"last_node_id": 10,
"last_link_id": 10,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": [
315,
474
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
636718080453780,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": [
210,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 10
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": [
210,
270
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 9
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": [
315,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"flower"
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
51.3,
182.49999999999997
],
"size": [
315,
98
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": []
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.8",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"anime\\artUniverse_sdxlV60.safetensors"
]
},
{
"id": 10,
"type": "VAELoader",
"pos": [
61.897062649999256,
347.8818042399999
],
"size": [
270,
58
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
10
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "VAELoader"
},
"widgets_values": [
"sdxl_vae.safetensors"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
10,
0,
8,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [
0,
0
],
"scale": 1
},
"ue_links": [],
"links_added_by_ue": []
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB