From c037483f95459dedc2e163bd39e6014d39ecaa8a Mon Sep 17 00:00:00 2001 From: kazuya_bros Date: Sun, 28 Jun 2026 15:40:34 +0900 Subject: [PATCH] Initial public release prep --- .comfyignore | 8 + .gitignore | 6 + LICENSE | 22 + README.md | 115 ++ __init__.py | 5 + data/expression_parts.json | 53 + data/sample_visual_prompt_library.json | 132 ++ docs/publishing-checklist.md | 17 + js/library_manager.js | 1510 ++++++++++++++++++++++ js/visual_prompt_picker.js | 1614 ++++++++++++++++++++++++ pyproject.toml | 18 + visual_prompt_picker.py | 273 ++++ 12 files changed, 3773 insertions(+) create mode 100644 .comfyignore create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 data/expression_parts.json create mode 100644 data/sample_visual_prompt_library.json create mode 100644 docs/publishing-checklist.md create mode 100644 js/library_manager.js create mode 100644 js/visual_prompt_picker.js create mode 100644 pyproject.toml create mode 100644 visual_prompt_picker.py diff --git a/.comfyignore b/.comfyignore new file mode 100644 index 0000000..6325de7 --- /dev/null +++ b/.comfyignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +data/libraries/ +docs/dev/ +*.psd +*.clip +*.kra + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96fba46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +data/libraries/ +.DS_Store +Thumbs.db + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a5beeed --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 KAZUYA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c2fd17 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# ComfyUI Visual Prompt Library + +Thumbnail-based visual prompt part picker and library manager for ComfyUI. + +This custom node is meant for building a personal visual prompt dictionary: expressions, camera angles, lighting, backgrounds, poses, outfits, accessories, and other prompt fragments can be stored with thumbnails and selected visually inside a workflow. + +## Features + +- `Visual Prompt Picker` node outputs selected prompt fragments as one `STRING`. +- Sidebar library manager for editing shared JSON databases. +- Category tags and prompt tags. +- Thumbnail support with file upload, data URL, and recent ComfyUI output selection. +- Import and export JSON libraries. +- Search within the selected category. +- Remembers the last selected DB, category, and view mode in the browser. +- Three view modes: large thumbnail, small thumbnail, and text. + +## Why This Exists + +Many prompt selector tools focus on text tags, styles, wildcards, or preset buttons. This project focuses on a visual prompt dictionary workflow: + +- See a thumbnail. +- Pick the prompt part. +- Send the selected fragments into the ComfyUI graph as a `STRING`. + +It is especially useful for prompt parts that are hard to remember from text alone, such as facial expressions, poses, camera composition, and background types. + +## Installation + +Clone this repository into `ComfyUI/custom_nodes`. + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/kazuya-bros/ComfyUI-VisualPromptLibrary.git +``` + +Restart ComfyUI after installing or updating Python routes. + +For JavaScript-only changes, a browser hard reload is usually enough. + +## Usage + +1. Add `Visual Prompt Picker` to your workflow. +2. Open the `Visual Prompt Library` sidebar tab. +3. Create or select a DB. +4. Add category tags, such as `表情`, `画角`, or `背景`. +5. Add prompt tags with labels, prompt text, and optional thumbnails. +6. In the node UI, load the DB and click prompt cards. +7. Connect the `prompt` output to a text prompt node. + +Selected fragments are joined by the node's `delimiter` setting. + +## Library JSON + +Shared databases are stored locally under: + +```text +data/libraries/*.json +``` + +This folder is ignored by git so private prompt libraries are not published accidentally. + +A small starter sample is included: + +```text +data/sample_visual_prompt_library.json +``` + +Each category contains prompt parts: + +```json +{ + "id": "gentle_smile", + "label": "微笑み", + "prompt": "gentle smile, relaxed eyes, closed mouth", + "negative": "", + "thumbnail": "" +} +``` + +`thumbnail` can be empty, a file path, a ComfyUI `/view?...` URL, or a data URL. The manager UI can also resize a selected image into a compact data URL. + +## Sample Data Policy + +The included sample prompts are intentionally small and original. Do not copy paid prompt dictionaries, books, or third-party datasets verbatim into a public repository unless you have permission. + +For personal use, imported JSON libraries stay in `data/libraries/`. + +## Publishing Status + +This repository is prepared for personal GitHub publishing. + +Before publishing to Comfy Registry: + +- Confirm that `PublisherId = "kazuya-bros"` matches your Comfy Registry publisher ID. +- Add screenshots. +- Confirm the package name and version. + +See: + +```text +docs/publishing-checklist.md +``` + +## Similar Tools + +This project overlaps with prompt selector and prompt management tools, but the intended focus is different: + +- Text prompt selectors and style selectors are good for preset text workflows. +- Wildcard tools are good for random or combinatorial prompt generation. +- Visual Prompt Library is for manually choosing prompt fragments from thumbnails and returning them as graph output. + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9b76072 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from .visual_prompt_picker import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/data/expression_parts.json b/data/expression_parts.json new file mode 100644 index 0000000..d30ce28 --- /dev/null +++ b/data/expression_parts.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "categories": [ + { + "id": "expression", + "label": "Expression", + "parts": [ + { + "id": "gentle_smile", + "label": "微笑み", + "prompt": "gentle smile, relaxed eyes, closed mouth", + "negative": "angry expression, crying, open mouth", + "thumbnail": "" + }, + { + "id": "angry", + "label": "怒り", + "prompt": "angry expression, narrowed eyes, tense mouth", + "negative": "gentle smile, relaxed eyes", + "thumbnail": "" + }, + { + "id": "surprised", + "label": "驚き", + "prompt": "surprised expression, wide eyes, small open mouth", + "negative": "sleepy expression, closed eyes", + "thumbnail": "" + }, + { + "id": "crying", + "label": "泣き", + "prompt": "crying, tearful eyes, trembling lips", + "negative": "smile, happy expression", + "thumbnail": "" + }, + { + "id": "sleepy", + "label": "眠そう", + "prompt": "sleepy expression, half-closed eyes", + "negative": "wide eyes, surprised expression", + "thumbnail": "" + }, + { + "id": "embarrassed", + "label": "照れ", + "prompt": "embarrassed blush, looking away, small smile", + "negative": "angry expression, crying", + "thumbnail": "" + } + ] + } + ] +} diff --git a/data/sample_visual_prompt_library.json b/data/sample_visual_prompt_library.json new file mode 100644 index 0000000..e0cf7aa --- /dev/null +++ b/data/sample_visual_prompt_library.json @@ -0,0 +1,132 @@ +{ + "version": 1, + "meta": { + "title": "Sample Visual Prompt Library", + "description": "Small starter library for testing Visual Prompt Library. Prompts are original short examples." + }, + "categories": [ + { + "id": "expression", + "label": "表情", + "parts": [ + { + "id": "expressionless", + "label": "無表情", + "prompt": "expressionless, neutral face, relaxed mouth", + "negative": "", + "thumbnail": "" + }, + { + "id": "gentle_smile", + "label": "微笑み", + "prompt": "gentle smile, relaxed eyes, closed mouth", + "negative": "", + "thumbnail": "" + }, + { + "id": "angry", + "label": "怒り", + "prompt": "angry expression, narrowed eyes, tense mouth", + "negative": "", + "thumbnail": "" + }, + { + "id": "embarrassed", + "label": "照れ", + "prompt": "embarrassed blush, looking away, small smile", + "negative": "", + "thumbnail": "" + } + ] + }, + { + "id": "camera_composition", + "label": "画角 / 構図", + "parts": [ + { + "id": "portrait_close_up", + "label": "顔アップ", + "prompt": "close-up portrait, face focus", + "negative": "", + "thumbnail": "" + }, + { + "id": "upper_body", + "label": "上半身", + "prompt": "upper body shot, waist up", + "negative": "", + "thumbnail": "" + }, + { + "id": "full_body", + "label": "全身", + "prompt": "full body, standing pose, entire character visible", + "negative": "", + "thumbnail": "" + }, + { + "id": "low_angle", + "label": "ローアングル", + "prompt": "low angle view, dynamic perspective", + "negative": "", + "thumbnail": "" + } + ] + }, + { + "id": "lighting", + "label": "光", + "parts": [ + { + "id": "soft_light", + "label": "柔らかい光", + "prompt": "soft lighting, gentle shadows", + "negative": "", + "thumbnail": "" + }, + { + "id": "rim_light", + "label": "リムライト", + "prompt": "rim lighting, highlighted silhouette", + "negative": "", + "thumbnail": "" + }, + { + "id": "backlight", + "label": "逆光", + "prompt": "backlighting, glowing edge light", + "negative": "", + "thumbnail": "" + } + ] + }, + { + "id": "background", + "label": "背景", + "parts": [ + { + "id": "simple_room", + "label": "室内", + "prompt": "simple room interior, clean background", + "negative": "", + "thumbnail": "" + }, + { + "id": "city_street", + "label": "街", + "prompt": "city street background, urban scenery", + "negative": "", + "thumbnail": "" + }, + { + "id": "forest", + "label": "森", + "prompt": "forest background, natural scenery", + "negative": "", + "thumbnail": "" + } + ] + } + ] +} + diff --git a/docs/publishing-checklist.md b/docs/publishing-checklist.md new file mode 100644 index 0000000..8d43294 --- /dev/null +++ b/docs/publishing-checklist.md @@ -0,0 +1,17 @@ +# Publishing Checklist + +This project is currently prepared for personal GitHub publishing first. + +## Before Pushing + +- Confirm that `PublisherId = "kazuya-bros"` matches your Comfy Registry publisher ID before Registry publishing. +- Add screenshots to the README if available. +- Keep private libraries out of git. `data/libraries/` is ignored. +- Do not copy third-party prompt dictionaries verbatim into sample data. + +## Registry Later + +- Create a publisher at Comfy Registry. +- Create a Registry API key. +- Run `comfy node publish` from this directory, or set up GitHub Actions. +- Bump `version` in `pyproject.toml` for each release. diff --git a/js/library_manager.js b/js/library_manager.js new file mode 100644 index 0000000..5647169 --- /dev/null +++ b/js/library_manager.js @@ -0,0 +1,1510 @@ +import { app } from "../../scripts/app.js"; + +const API_ROOT = "/visual_prompt_library"; +const STYLE_ID = "visual-prompt-library-manager-style"; +const STATE_STORAGE_KEY = "visual_prompt_library_manager_state"; + +function ensureStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + .vplm-root { + box-sizing: border-box; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + color: #f3f4f6; + background: #101113; + font-family: Arial, sans-serif; + overflow-y: auto; + } + .vplm-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + .vplm-db-row { + align-items: flex-end; + } + .vplm-title { + font-size: 14px; + font-weight: 700; + } + .vplm-section-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 7px; + color: #dce7f5; + font-size: 12px; + font-weight: 700; + } + .vplm-title-actions { + display: flex; + align-items: center; + gap: 6px; + } + .vplm-help { + color: #9fb0c3; + font-size: 11px; + line-height: 1.35; + } + .vplm-field { + display: grid; + gap: 4px; + } + .vplm-field label { + color: #9fb0c3; + font-size: 11px; + } + .vplm-badge { + border: 1px solid #3b4048; + border-radius: 999px; + padding: 2px 7px; + color: #b8d9ff; + background: #162435; + font-size: 10px; + white-space: nowrap; + } + .vplm-btn, + .vplm-input, + .vplm-select, + .vplm-textarea { + box-sizing: border-box; + border: 1px solid #3b4048; + border-radius: 5px; + color: #f3f4f6; + background: #171a1f; + font-size: 12px; + outline: none; + } + .vplm-btn { + padding: 6px 8px; + cursor: pointer; + white-space: nowrap; + } + .vplm-btn:hover { + background: #22262d; + } + .vplm-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .vplm-btn:disabled:hover { + background: #171a1f; + border-color: #3b4048; + } + .vplm-btn-danger:hover { + background: #542526; + border-color: #8f4447; + } + .vplm-btn-add { + border-color: #df5f9a; + color: #fff5fb; + background: #9f2f66; + font-weight: 700; + } + .vplm-btn-add:hover { + border-color: #f384b6; + background: #bd3b7a; + } + .vplm-input, + .vplm-select { + height: 30px; + padding: 5px 8px; + min-width: 0; + } + .vplm-textarea { + width: 100%; + min-height: 66px; + padding: 7px 8px; + resize: vertical; + } + .vplm-grow { + flex: 1 1 140px; + min-width: 0; + } + .vplm-panel { + border: 1px solid #2c2f34; + border-radius: 7px; + background: #12151a; + padding: 8px; + } + .vplm-section { + display: grid; + gap: 8px; + } + .vplm-main { + min-height: 0; + display: grid; + grid-template-columns: minmax(130px, 0.85fr) minmax(180px, 1.2fr); + gap: 8px; + flex: 1 1 auto; + overflow: hidden; + } + .vplm-list { + min-height: 0; + overflow-y: auto; + display: grid; + align-content: start; + gap: 6px; + } + .vplm-panel-list { + min-height: 0; + display: flex; + flex-direction: column; + gap: 7px; + } + .vplm-scroll { + min-height: 0; + overflow-y: auto; + display: grid; + align-content: start; + gap: 6px; + } + .vplm-item { + border: 1px solid #333943; + border-radius: 6px; + padding: 7px; + background: #171a1f; + cursor: pointer; + } + .vplm-item-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + } + .vplm-item-actions { + display: flex; + gap: 4px; + } + .vplm-icon-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #3b4048; + border-radius: 5px; + color: #f3f4f6; + background: #171a1f; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; + } + .vplm-icon-svg { + width: 15px; + height: 15px; + display: block; + stroke: currentColor; + stroke-width: 2.3; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + pointer-events: none; + } + .vplm-icon-btn:hover { + background: #22262d; + } + .vplm-icon-btn-danger:hover { + background: #542526; + border-color: #8f4447; + } + .vplm-item[data-active="true"] { + border-color: #62a8ed; + background: #1c2733; + } + .vplm-item-title { + font-size: 12px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .vplm-item-sub { + margin-top: 3px; + color: #9fb0c3; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .vplm-editor { + min-height: 0; + overflow-y: auto; + display: grid; + gap: 8px; + } + .vplm-thumb-row { + display: grid; + grid-template-columns: 1fr 64px; + gap: 8px; + align-items: center; + } + .vplm-thumb-preview { + display: grid; + gap: 5px; + } + .vplm-thumb { + width: 64px; + height: 64px; + border: 1px solid #3b4048; + border-radius: 6px; + object-fit: contain; + background: #0d1014; + } + .vplm-thumb-clear { + width: 64px; + padding-inline: 0; + text-align: center; + } + .vplm-output-panel { + display: grid; + gap: 8px; + } + .vplm-output-grid { + max-height: 260px; + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); + gap: 7px; + padding: 3px; + border: 1px solid #2c2f34; + border-radius: 6px; + background: #0d1014; + } + .vplm-output-item { + min-width: 0; + border: 1px solid #333943; + border-radius: 6px; + padding: 4px; + background: #171a1f; + color: #f3f4f6; + cursor: pointer; + display: grid; + gap: 4px; + } + .vplm-output-item:hover { + border-color: #62a8ed; + background: #1c2733; + } + .vplm-output-item img { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; + background: #101318; + border-radius: 4px; + } + .vplm-output-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + color: #9fb0c3; + } + .vplm-prompt-list { + min-height: 0; + overflow-y: auto; + display: grid; + align-content: start; + gap: 8px; + } + .vplm-prompt-list[data-view-mode="large"] { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + } + .vplm-prompt-list[data-view-mode="small"] { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } + .vplm-prompt-list[data-view-mode="text"] { + grid-template-columns: 1fr; + gap: 6px; + } + .vplm-prompt-card { + border: 1px solid #333943; + border-radius: 6px; + background: #171a1f; + cursor: pointer; + overflow: hidden; + } + .vplm-prompt-card[data-active="true"] { + border-color: #62a8ed; + background: #1c2733; + } + .vplm-prompt-thumb { + width: 100%; + aspect-ratio: 1 / 1; + display: grid; + place-items: center; + background: #20252d; + overflow: hidden; + } + .vplm-prompt-thumb img { + width: 100%; + height: 100%; + object-fit: contain; + background: #0d1014; + } + .vplm-prompt-thumb span { + color: #9fb0c3; + font-size: 11px; + } + .vplm-prompt-body { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 7px; + } + .vplm-prompt-list[data-view-mode="large"] .vplm-prompt-thumb { + aspect-ratio: 1 / 1; + } + .vplm-prompt-list[data-view-mode="small"] .vplm-prompt-thumb { + aspect-ratio: 1 / 1; + } + .vplm-prompt-list[data-view-mode="text"] .vplm-prompt-card { + padding: 0; + } + .vplm-prompt-list[data-view-mode="text"] .vplm-prompt-thumb { + display: none; + } + .vplm-status { + color: #9fb0c3; + font-size: 11px; + min-height: 16px; + } + .vplm-empty { + border: 1px dashed #333943; + border-radius: 6px; + padding: 10px; + color: #9fb0c3; + font-size: 11px; + text-align: center; + } + .vplm-hidden { + display: none; + } + .vplm-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + place-items: center; + padding: 18px; + background: rgba(0, 0, 0, 0.58); + } + .vplm-modal { + width: min(520px, calc(100vw - 36px)); + max-height: calc(100vh - 36px); + overflow-y: auto; + border: 1px solid #3b4048; + border-radius: 8px; + background: #111418; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5); + padding: 12px; + display: grid; + gap: 10px; + } + .vplm-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + `; + document.head.appendChild(style); +} + +function slugify(value) { + const text = String(value || "item") + .normalize("NFKC") + .trim() + .toLowerCase(); + let result = ""; + for (const char of text) { + if (/^[a-z0-9_-]$/.test(char) || (char.charCodeAt(0) > 127 && !/[\\/:*?"<>|#%&{}$!`'@+=,;[\]\s]/.test(char))) { + result += char; + } else { + result += "_"; + } + } + return result.replace(/^_+|_+$/g, "").replace(/_+/g, "_") || "item"; +} + +function uniqueId(base, exists) { + const root = slugify(base); + let id = root; + let index = 2; + while (exists(id)) { + id = `${root}_${index}`; + index += 1; + } + return id; +} + +async function fetchJson(url, options = {}) { + const method = String(options.method || "GET").toUpperCase(); + let requestUrl = url; + if (method === "GET") { + const parsed = new URL(url, window.location.origin); + parsed.searchParams.set("_", String(Date.now())); + requestUrl = `${parsed.pathname}${parsed.search}`; + } + const response = await fetch(requestUrl, { cache: "no-store", ...options }); + const text = await response.text(); + const trimmed = text.trim(); + let payload = {}; + if (trimmed) { + const contentType = response.headers.get("content-type") || ""; + const looksJson = contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("["); + if (looksJson) { + try { + payload = JSON.parse(trimmed); + } catch (error) { + payload = { error: trimmed }; + } + } else { + payload = { error: trimmed }; + } + } + if (!response.ok) { + if (response.status === 404 && String(payload.error || "").includes("404")) { + throw new Error("APIが見つかりません。ComfyUI本体を再起動してください。"); + } + throw new Error(payload.error || `HTTP ${response.status}`); + } + return payload; +} + +function imageViewUrl(image) { + const params = new URLSearchParams({ + filename: image.filename || "", + type: image.type || "output", + }); + if (image.subfolder) params.set("subfolder", image.subfolder); + return `/view?${params.toString()}`; +} + +async function getRecentOutputsFromHistory(limit = 80) { + const payload = await fetchJson(`/history?max_items=${limit}`); + const entries = Object.values(payload || {}); + const images = []; + entries.forEach((entry) => { + const outputs = entry?.outputs || {}; + Object.values(outputs).forEach((output) => { + (output?.images || []).forEach((image) => { + if (!image?.filename) return; + const normalized = { + filename: image.filename, + subfolder: image.subfolder || "", + type: image.type || "output", + }; + images.push({ + ...normalized, + url: imageViewUrl(normalized), + }); + }); + }); + }); + return images.slice(0, limit); +} + +function resizeImageBlobToDataUrl(blob, maxSize = 320) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + reader.onload = () => { + const image = new Image(); + image.onerror = () => reject(new Error("Failed to load selected image.")); + image.onload = () => { + const scale = Math.min(1, maxSize / Math.max(image.width, image.height)); + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + canvas.getContext("2d").drawImage(image, 0, 0, width, height); + resolve(canvas.toDataURL("image/webp", 0.82)); + }; + image.src = reader.result; + }; + reader.readAsDataURL(blob); + }); +} + +function resizeImageFileToDataUrl(file, maxSize = 320) { + return resizeImageBlobToDataUrl(file, maxSize); +} + +async function resizeImageUrlToDataUrl(url, maxSize = 320) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + return resizeImageBlobToDataUrl(blob, maxSize); +} + +function normalizeLibrary(raw) { + const categories = Array.isArray(raw?.categories) ? raw.categories : []; + return { + version: raw?.version || 1, + meta: raw?.meta || {}, + categories: categories.map((category) => ({ + id: category.id || slugify(category.label), + label: category.label || category.id || "カテゴリ", + parts: Array.isArray(category.parts) ? category.parts.map((part) => ({ + id: part.id || slugify(part.label || part.prompt), + label: part.label || part.id || part.prompt || "プロンプト", + prompt: part.prompt || "", + negative: part.negative || "", + thumbnail: part.thumbnail || "", + })) : [], + })), + }; +} + +function mergeLibrary(target, incoming) { + const source = normalizeLibrary(incoming); + let addedCategories = 0; + let addedParts = 0; + source.categories.forEach((category) => { + let dest = target.categories.find((item) => item.id === category.id || item.label === category.label); + if (!dest) { + const id = uniqueId(category.id || category.label, (candidate) => target.categories.some((item) => item.id === candidate)); + dest = { id, label: category.label, parts: [] }; + target.categories.push(dest); + addedCategories += 1; + } + category.parts.forEach((part) => { + const duplicate = dest.parts.some((item) => item.prompt === part.prompt || item.label === part.label); + if (duplicate) return; + const id = uniqueId(part.id || part.label || part.prompt, (candidate) => dest.parts.some((item) => item.id === candidate)); + dest.parts.push({ ...part, id }); + addedParts += 1; + }); + }); + return { addedCategories, addedParts }; +} + +function createIconSvg(type) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "vplm-icon-svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + + const addPath = (d) => { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", d); + svg.appendChild(path); + }; + + if (type === "edit") { + addPath("M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.1-3.1a5 5 0 0 1-6.6 6.6L7.8 19.2a2 2 0 1 1-3-3l6.4-6.4a5 5 0 0 1 6.6-6.6z"); + } else { + addPath("M3 6h18"); + addPath("M8 6V4h8v2"); + addPath("M19 6l-1 14H6L5 6"); + addPath("M10 11v5"); + addPath("M14 11v5"); + } + return svg; +} + +function keepEventInsideModal(event) { + event.stopPropagation(); +} + +function protectTextEntry(element) { + ["keydown", "keyup", "keypress", "paste"].forEach((eventType) => { + element.addEventListener(eventType, keepEventInsideModal); + }); +} + +function loadUiState() { + try { + return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}"); + } catch { + return {}; + } +} + +function saveUiState(state) { + try { + localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Ignore storage failures; the picker still works without persistence. + } +} + +class VisualPromptLibraryManager { + constructor() { + ensureStyle(); + const savedState = loadUiState(); + this.element = document.createElement("div"); + this.element.className = "vplm-root"; + this.libraries = []; + this.libraryName = savedState.libraryName || "default"; + this.library = { version: 1, meta: {}, categories: [] }; + this.selectedCategoryId = savedState.categoryByLibrary?.[this.libraryName] || ""; + this.selectedPartId = ""; + this.search = ""; + this.status = ""; + this.viewMode = savedState.viewMode || "large"; + this.uiState = savedState; + this.dirty = false; + this.autosaveTimer = null; + this.initialized = false; + this.render(); + this.refreshLibraries({ loadSelected: true }); + } + + rememberUiState() { + this.uiState = { + ...this.uiState, + libraryName: this.libraryName, + viewMode: this.viewMode, + categoryByLibrary: { + ...(this.uiState.categoryByLibrary || {}), + [this.libraryName]: this.selectedCategoryId, + }, + }; + saveUiState(this.uiState); + } + + setStatus(message) { + this.status = message || ""; + this.render(); + } + + markDirty() { + this.dirty = true; + this.scheduleAutoSave(); + } + + scheduleAutoSave() { + window.clearTimeout(this.autosaveTimer); + this.autosaveTimer = window.setTimeout(() => this.saveLibrary(true), 650); + } + + async refreshLibraries(options = {}) { + try { + const payload = await fetchJson(`${API_ROOT}/libraries`); + this.libraries = payload.libraries || []; + if (!this.libraries.some((item) => item.name === this.libraryName) && this.libraries[0]) { + this.libraryName = this.libraries[0].name; + } + this.rememberUiState(); + if (options.loadSelected) { + await this.loadLibrary({ silent: true }); + this.initialized = true; + this.setStatus(`復元済み: ${this.libraryName}`); + } else { + this.render(); + } + } catch (error) { + this.setStatus(`DB一覧の取得に失敗: ${error.message}`); + } + } + + async loadLibrary(options = {}) { + try { + const payload = await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(this.libraryName)}`); + this.libraryName = payload.name || this.libraryName; + this.library = normalizeLibrary(payload.library); + const rememberedCategoryId = this.uiState.categoryByLibrary?.[this.libraryName] || ""; + this.selectedCategoryId = this.library.categories.some((category) => category.id === rememberedCategoryId) + ? rememberedCategoryId + : (this.library.categories[0]?.id || ""); + this.selectedPartId = this.currentCategory()?.parts?.[0]?.id || ""; + this.dirty = false; + this.rememberUiState(); + if (!options.silent) { + this.setStatus(`読み込み済み: ${this.libraryName}`); + } else { + this.render(); + } + } catch (error) { + this.setStatus(`読み込み失敗: ${error.message}`); + } + } + + async saveLibrary(auto = false) { + window.clearTimeout(this.autosaveTimer); + try { + const payload = await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(slugify(this.libraryName))}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ library: this.library }), + }); + this.libraryName = payload.name || this.libraryName; + this.dirty = false; + await this.refreshLibraries(); + this.setStatus(auto ? `自動保存済み: ${this.libraryName}` : `保存済み: ${this.libraryName}`); + } catch (error) { + this.setStatus(`保存失敗: ${error.message}`); + } + } + + async createLibrary(name) { + const libraryName = slugify(name); + if (!libraryName) return; + try { + const payload = await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(libraryName)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ library: { version: 1, meta: {}, categories: [] } }), + }); + this.libraryName = payload.name || libraryName; + this.library = normalizeLibrary(payload.library); + this.selectedCategoryId = ""; + this.selectedPartId = ""; + this.dirty = false; + this.rememberUiState(); + await this.refreshLibraries(); + this.setStatus(`作成済み: ${this.libraryName}`); + } catch (error) { + this.setStatus(`DB作成失敗: ${error.message}`); + } + } + + async deleteLibrary() { + const name = this.libraryName; + if (!name) return; + if (!confirm(`DB「${name}」を削除しますか?`)) return; + try { + await fetchJson(`${API_ROOT}/libraries/${encodeURIComponent(name)}`, { method: "DELETE" }); + await this.refreshLibraries(); + this.libraryName = this.libraries[0]?.name || "default"; + this.selectedCategoryId = this.uiState.categoryByLibrary?.[this.libraryName] || ""; + this.rememberUiState(); + await this.loadLibrary(); + } catch (error) { + this.setStatus(`DB削除失敗: ${error.message}`); + } + } + + currentCategory() { + return this.library.categories.find((category) => category.id === this.selectedCategoryId) || null; + } + + currentPart() { + const category = this.currentCategory(); + return category?.parts?.find((part) => part.id === this.selectedPartId) || null; + } + + openCategoryModal(category = null) { + this.showCategoryModal(category); + } + + openLibraryModal() { + this.showLibraryModal(); + } + + openPromptModal(part = null) { + this.showPromptModal(part); + } + + deleteCategory(categoryId) { + const category = this.library.categories.find((item) => item.id === categoryId); + if (!category) return; + if (!confirm(`カテゴリ「${category.label}」を削除しますか?`)) return; + this.library.categories = this.library.categories.filter((item) => item.id !== category.id); + this.selectedCategoryId = this.library.categories[0]?.id || ""; + this.selectedPartId = this.currentCategory()?.parts?.[0]?.id || ""; + this.rememberUiState(); + this.markDirty(); + this.render(); + } + + deletePart(partId) { + const category = this.currentCategory(); + const part = category?.parts?.find((item) => item.id === partId); + if (!category || !part) return; + if (!confirm(`プロンプト「${part.label}」を削除しますか?`)) return; + category.parts = category.parts.filter((item) => item.id !== part.id); + this.selectedPartId = category.parts[0]?.id || ""; + this.markDirty(); + this.render(); + } + + exportLibrary() { + const blob = new Blob([JSON.stringify(this.library, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `visual_prompt_library_${this.libraryName}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } + + importLibraryFile(file) { + if (!file) return; + const reader = new FileReader(); + reader.onerror = () => this.setStatus("インポートファイルの読み込みに失敗"); + reader.onload = () => { + try { + const incoming = JSON.parse(String(reader.result || "")); + const result = mergeLibrary(this.library, incoming); + this.markDirty(); + this.setStatus(`インポート済み: プロンプト ${result.addedParts} 件 / カテゴリ ${result.addedCategories} 件`); + } catch (error) { + this.setStatus(`インポート失敗: ${error.message}`); + } + }; + reader.readAsText(file); + } + + closeModal(overlay) { + overlay?.remove(); + } + + showCategoryModal(category = null) { + const isEdit = Boolean(category); + const draft = { + label: category?.label || "", + }; + const overlay = this.createModal(isEdit ? "カテゴリタグを編集" : "カテゴリタグを追加"); + const modal = overlay.firstElementChild; + + const field = this.createField("カテゴリタグ", "input", draft.label, "カテゴリタグ"); + field.input.oninput = () => { draft.label = field.input.value; }; + modal.appendChild(field.wrap); + + modal.appendChild(this.createModalActions(overlay, () => { + const label = draft.label.trim() || "新規カテゴリタグ"; + if (isEdit) { + category.label = label; + this.selectedCategoryId = category.id; + } else { + const id = uniqueId(label, (candidate) => this.library.categories.some((item) => item.id === candidate)); + this.library.categories.push({ id, label, parts: [] }); + this.selectedCategoryId = id; + this.selectedPartId = ""; + } + this.rememberUiState(); + this.markDirty(); + this.closeModal(overlay); + this.render(); + })); + document.body.appendChild(overlay); + field.input.focus(); + } + + showLibraryModal() { + const draft = { name: "" }; + const overlay = this.createModal("DBを追加"); + const modal = overlay.firstElementChild; + const field = this.createField("DB名", "input", "", "my_prompt_library"); + field.input.oninput = () => { draft.name = field.input.value; }; + modal.appendChild(field.wrap); + modal.appendChild(this.createModalActions(overlay, () => { + const name = draft.name.trim(); + if (!name) return; + this.closeModal(overlay); + this.createLibrary(name); + })); + document.body.appendChild(overlay); + field.input.focus(); + } + + showPromptModal(part = null) { + const category = this.currentCategory(); + if (!category) return; + const isEdit = Boolean(part); + const draft = { + label: part?.label || "", + prompt: part?.prompt || "", + thumbnail: part?.thumbnail || "", + }; + const overlay = this.createModal(isEdit ? "プロンプトタグを編集" : "プロンプトタグを追加"); + const modal = overlay.firstElementChild; + + const labelField = this.createField("ラベル", "input", draft.label, "ラベル"); + labelField.input.oninput = () => { draft.label = labelField.input.value; }; + modal.appendChild(labelField.wrap); + + const promptField = this.createField("プロンプト", "textarea", draft.prompt, "プロンプト"); + promptField.input.oninput = () => { draft.prompt = promptField.input.value; }; + modal.appendChild(promptField.wrap); + + const thumbRow = document.createElement("div"); + thumbRow.className = "vplm-thumb-row"; + const thumbField = this.createField("サムネイルパスまたはdata URL", "input", draft.thumbnail, "サムネイルパスまたはdata URL"); + const thumb = document.createElement("img"); + thumb.className = "vplm-thumb"; + if (draft.thumbnail) thumb.src = draft.thumbnail; + const thumbPreview = document.createElement("div"); + thumbPreview.className = "vplm-thumb-preview"; + const clearThumb = document.createElement("button"); + clearThumb.className = "vplm-btn vplm-thumb-clear"; + clearThumb.type = "button"; + clearThumb.textContent = "削除"; + clearThumb.title = "サムネイルを削除"; + clearThumb.disabled = !draft.thumbnail; + thumbField.input.oninput = () => { + draft.thumbnail = thumbField.input.value; + thumb.src = draft.thumbnail || ""; + clearThumb.disabled = !draft.thumbnail; + }; + clearThumb.onclick = (event) => { + event.stopPropagation(); + draft.thumbnail = ""; + thumbField.input.value = ""; + thumb.removeAttribute("src"); + clearThumb.disabled = true; + }; + thumbRow.appendChild(thumbField.wrap); + thumbPreview.appendChild(thumb); + thumbPreview.appendChild(clearThumb); + thumbRow.appendChild(thumbPreview); + modal.appendChild(thumbRow); + + const fileRow = document.createElement("div"); + fileRow.className = "vplm-row"; + const file = document.createElement("input"); + file.type = "file"; + file.accept = "image/*"; + file.onchange = async () => { + const imageFile = file.files?.[0]; + if (!imageFile) return; + draft.thumbnail = await resizeImageFileToDataUrl(imageFile); + thumbField.input.value = draft.thumbnail; + thumb.src = draft.thumbnail; + clearThumb.disabled = false; + }; + fileRow.appendChild(file); + modal.appendChild(fileRow); + + const outputPanel = document.createElement("div"); + outputPanel.className = "vplm-output-panel"; + const outputActions = document.createElement("div"); + outputActions.className = "vplm-row"; + const recentButton = document.createElement("button"); + recentButton.className = "vplm-btn"; + recentButton.type = "button"; + recentButton.textContent = "最近の出力から選択"; + const openOutputButton = document.createElement("button"); + openOutputButton.className = "vplm-btn"; + openOutputButton.type = "button"; + openOutputButton.textContent = "出力フォルダを開く"; + const outputGrid = document.createElement("div"); + outputGrid.className = "vplm-output-grid"; + outputGrid.style.display = "none"; + + recentButton.onclick = async (event) => { + event.stopPropagation(); + await this.loadRecentOutputs(outputGrid, async (item) => { + draft.thumbnail = await resizeImageUrlToDataUrl(item.url); + thumbField.input.value = draft.thumbnail; + thumb.src = draft.thumbnail; + clearThumb.disabled = false; + }); + }; + openOutputButton.onclick = async (event) => { + event.stopPropagation(); + try { + await fetchJson(`${API_ROOT}/open_output_folder`, { method: "POST" }); + } catch (error) { + outputGrid.style.display = ""; + outputGrid.textContent = `出力フォルダを開けません: ${error.message}\nこの機能はComfyUI本体の再起動後に有効になります。`; + } + }; + + outputActions.appendChild(recentButton); + outputActions.appendChild(openOutputButton); + outputPanel.appendChild(outputActions); + outputPanel.appendChild(outputGrid); + modal.appendChild(outputPanel); + + modal.appendChild(this.createModalActions(overlay, () => { + const label = draft.label.trim() || "新規プロンプト"; + if (isEdit) { + part.label = label; + part.prompt = draft.prompt; + part.thumbnail = draft.thumbnail; + this.selectedPartId = part.id; + } else { + const id = uniqueId(label || draft.prompt || "prompt", (candidate) => category.parts.some((item) => item.id === candidate)); + category.parts.push({ id, label, prompt: draft.prompt, negative: "", thumbnail: draft.thumbnail }); + this.selectedPartId = id; + } + this.markDirty(); + this.closeModal(overlay); + this.render(); + })); + document.body.appendChild(overlay); + labelField.input.focus(); + } + + async loadRecentOutputs(container, onSelect) { + container.style.display = ""; + container.textContent = "読み込み中..."; + try { + let images = []; + let apiError = null; + try { + const payload = await fetchJson(`${API_ROOT}/recent_outputs?limit=80`); + images = Array.isArray(payload.images) ? payload.images : []; + } catch (error) { + apiError = error; + try { + images = await getRecentOutputsFromHistory(80); + } catch (historyError) { + throw new Error(`${apiError.message} / 履歴APIも取得できません: ${historyError.message}`); + } + } + container.innerHTML = ""; + + if (!images.length) { + container.textContent = apiError + ? `最近の出力画像が見つかりません。出力フォルダAPIは未使用です: ${apiError.message}` + : "最近の出力画像が見つかりません。"; + return; + } + + images.forEach((item) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "vplm-output-item"; + button.title = item.subfolder ? `${item.subfolder}/${item.filename}` : item.filename; + + const image = document.createElement("img"); + image.src = item.url; + image.alt = item.filename || ""; + const name = document.createElement("div"); + name.className = "vplm-output-name"; + name.textContent = item.filename || ""; + + button.appendChild(image); + button.appendChild(name); + button.onclick = async (event) => { + event.stopPropagation(); + button.disabled = true; + try { + await onSelect(item); + } finally { + button.disabled = false; + } + }; + container.appendChild(button); + }); + } catch (error) { + container.textContent = `最近の出力を取得できません: ${error.message}`; + } + } + + createModal(titleText) { + const overlay = document.createElement("div"); + overlay.className = "vplm-overlay"; + ["pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick"].forEach((eventType) => { + overlay.addEventListener(eventType, (event) => event.stopPropagation()); + }); + overlay.onclick = (event) => { + if (event.target === overlay) this.closeModal(overlay); + }; + const modal = document.createElement("div"); + modal.className = "vplm-modal"; + const title = document.createElement("div"); + title.className = "vplm-title"; + title.textContent = titleText; + modal.appendChild(title); + overlay.appendChild(modal); + return overlay; + } + + createField(labelText, type, value, placeholder) { + const wrap = document.createElement("div"); + wrap.className = "vplm-field"; + const label = document.createElement("label"); + label.textContent = labelText; + const input = type === "textarea" ? document.createElement("textarea") : document.createElement("input"); + input.className = type === "textarea" ? "vplm-textarea" : "vplm-input"; + input.value = value || ""; + input.placeholder = placeholder || ""; + protectTextEntry(input); + wrap.appendChild(label); + wrap.appendChild(input); + return { wrap, input }; + } + + createModalActions(overlay, onSave) { + const actions = document.createElement("div"); + actions.className = "vplm-modal-actions"; + const cancel = document.createElement("button"); + cancel.className = "vplm-btn"; + cancel.textContent = "キャンセル"; + cancel.onclick = (event) => { + keepEventInsideModal(event); + this.closeModal(overlay); + }; + const save = document.createElement("button"); + save.className = "vplm-btn"; + save.textContent = "保存"; + save.onclick = (event) => { + keepEventInsideModal(event); + onSave(); + }; + actions.appendChild(cancel); + actions.appendChild(save); + return actions; + } + + render() { + const el = this.element; + el.innerHTML = ""; + + const header = document.createElement("div"); + header.className = "vplm-row"; + const title = document.createElement("div"); + title.className = "vplm-title"; + title.textContent = "ビジュアルプロンプトライブラリ"; + header.appendChild(title); + if (this.dirty) { + const badge = document.createElement("span"); + badge.className = "vplm-badge"; + badge.textContent = "未保存"; + header.appendChild(badge); + } + el.appendChild(header); + + const dbPanel = document.createElement("div"); + dbPanel.className = "vplm-panel vplm-section"; + + const dbTitle = document.createElement("div"); + dbTitle.className = "vplm-section-title"; + const dbTitleText = document.createElement("span"); + dbTitleText.textContent = "1. DB"; + const dbTitleActions = document.createElement("div"); + dbTitleActions.className = "vplm-title-actions"; + const addDb = document.createElement("button"); + addDb.className = "vplm-btn vplm-btn-add"; + addDb.textContent = "+ 追加"; + addDb.onclick = () => this.openLibraryModal(); + dbTitleActions.appendChild(addDb); + dbTitle.appendChild(dbTitleText); + dbTitle.appendChild(dbTitleActions); + dbPanel.appendChild(dbTitle); + + const dbHelp = document.createElement("div"); + dbHelp.className = "vplm-help"; + dbHelp.textContent = "DBを選ぶとすぐ読み込みます。編集内容は自動保存されます。"; + dbPanel.appendChild(dbHelp); + + const dbRow = document.createElement("div"); + dbRow.className = "vplm-row vplm-db-row"; + const selectField = document.createElement("div"); + selectField.className = "vplm-field vplm-grow"; + const selectLabel = document.createElement("label"); + selectLabel.textContent = "DB"; + const select = document.createElement("select"); + select.className = "vplm-select"; + this.libraries.forEach((library) => { + const option = document.createElement("option"); + option.value = library.name; + option.textContent = `${library.label || library.name} (${library.categories}/${library.parts})`; + option.selected = library.name === this.libraryName; + select.appendChild(option); + }); + if (!this.libraries.length) { + const option = document.createElement("option"); + option.value = this.libraryName; + option.textContent = this.libraryName; + select.appendChild(option); + } + select.onchange = () => { + this.libraryName = select.value || "default"; + this.selectedCategoryId = this.uiState.categoryByLibrary?.[this.libraryName] || ""; + this.selectedPartId = ""; + this.rememberUiState(); + this.loadLibrary(); + }; + selectField.appendChild(selectLabel); + selectField.appendChild(select); + dbRow.appendChild(selectField); + + const deleteDb = document.createElement("button"); + deleteDb.className = "vplm-icon-btn vplm-icon-delete vplm-icon-btn-danger"; + deleteDb.appendChild(createIconSvg("delete")); + deleteDb.title = "DBを削除"; + deleteDb.disabled = this.libraryName === "default"; + deleteDb.onclick = () => this.deleteLibrary(); + dbRow.appendChild(deleteDb); + dbPanel.appendChild(dbRow); + + const dbButtons = document.createElement("div"); + dbButtons.className = "vplm-row"; + [ + ["エクスポート", () => this.exportLibrary()], + ].forEach(([label, onClick]) => { + const button = document.createElement("button"); + button.className = "vplm-btn"; + button.textContent = label; + button.onclick = onClick; + dbButtons.appendChild(button); + }); + const importInput = document.createElement("input"); + importInput.type = "file"; + importInput.accept = "application/json,.json"; + importInput.className = "vplm-hidden"; + importInput.onchange = () => { + this.importLibraryFile(importInput.files?.[0]); + importInput.value = ""; + }; + const importButton = document.createElement("button"); + importButton.className = "vplm-btn"; + importButton.textContent = "インポート"; + importButton.onclick = () => importInput.click(); + dbButtons.appendChild(importButton); + dbButtons.appendChild(importInput); + dbPanel.appendChild(dbButtons); + + const status = document.createElement("div"); + status.className = "vplm-status"; + status.textContent = this.status; + dbPanel.appendChild(status); + el.appendChild(dbPanel); + + const categoryPanel = document.createElement("div"); + categoryPanel.className = "vplm-panel vplm-section"; + const categoryTitle = document.createElement("div"); + categoryTitle.className = "vplm-section-title"; + const categoryTitleText = document.createElement("span"); + categoryTitleText.textContent = "2. カテゴリタグ"; + const categoryTitleActions = document.createElement("div"); + categoryTitleActions.className = "vplm-title-actions"; + const categoryCount = document.createElement("span"); + categoryCount.className = "vplm-badge"; + categoryCount.textContent = String(this.library.categories.length); + const addCategory = document.createElement("button"); + addCategory.className = "vplm-btn vplm-btn-add"; + addCategory.textContent = "+ 追加"; + addCategory.onclick = () => this.openCategoryModal(); + categoryTitleActions.appendChild(categoryCount); + categoryTitleActions.appendChild(addCategory); + categoryTitle.appendChild(categoryTitleText); + categoryTitle.appendChild(categoryTitleActions); + categoryPanel.appendChild(categoryTitle); + + const categoryHelp = document.createElement("div"); + categoryHelp.className = "vplm-help"; + categoryHelp.textContent = "カテゴリタグを選んで、配下のプロンプトタグを管理します。"; + categoryPanel.appendChild(categoryHelp); + + const categoryScroll = document.createElement("div"); + categoryScroll.className = "vplm-list"; + this.library.categories.forEach((category) => { + const item = document.createElement("div"); + item.className = "vplm-item"; + item.dataset.active = String(category.id === this.selectedCategoryId); + item.onclick = () => { + this.selectedCategoryId = category.id; + this.selectedPartId = category.parts[0]?.id || ""; + this.rememberUiState(); + this.render(); + }; + const head = document.createElement("div"); + head.className = "vplm-item-head"; + const text = document.createElement("div"); + const label = document.createElement("div"); + label.className = "vplm-item-title"; + label.textContent = category.label; + const sub = document.createElement("div"); + sub.className = "vplm-item-sub"; + sub.textContent = `${category.parts?.length || 0} 件`; + text.appendChild(label); + text.appendChild(sub); + const actions = document.createElement("div"); + actions.className = "vplm-item-actions"; + const edit = document.createElement("button"); + edit.className = "vplm-icon-btn vplm-icon-edit"; + edit.appendChild(createIconSvg("edit")); + edit.title = "カテゴリを編集"; + edit.onclick = (event) => { + event.stopPropagation(); + this.openCategoryModal(category); + }; + const del = document.createElement("button"); + del.className = "vplm-icon-btn vplm-icon-delete vplm-icon-btn-danger"; + del.appendChild(createIconSvg("delete")); + del.title = "カテゴリを削除"; + del.onclick = (event) => { + event.stopPropagation(); + this.deleteCategory(category.id); + }; + actions.appendChild(edit); + actions.appendChild(del); + head.appendChild(text); + head.appendChild(actions); + item.appendChild(head); + categoryScroll.appendChild(item); + }); + if (!this.library.categories.length) { + const empty = document.createElement("div"); + empty.className = "vplm-empty"; + empty.textContent = "カテゴリはまだありません。"; + categoryScroll.appendChild(empty); + } + categoryPanel.appendChild(categoryScroll); + el.appendChild(categoryPanel); + + const promptPanel = document.createElement("div"); + promptPanel.className = "vplm-panel vplm-section"; + const promptTitle = document.createElement("div"); + promptTitle.className = "vplm-section-title"; + const category = this.currentCategory(); + const categoryLabel = category?.label || "カテゴリ未選択"; + const promptTitleText = document.createElement("span"); + promptTitleText.textContent = "3. プロンプトタグ"; + const promptTitleActions = document.createElement("div"); + promptTitleActions.className = "vplm-title-actions"; + const promptBadge = document.createElement("span"); + promptBadge.className = "vplm-badge"; + promptBadge.textContent = categoryLabel; + promptTitleActions.appendChild(promptBadge); + if (category) { + const viewButton = document.createElement("button"); + viewButton.className = "vplm-btn"; + viewButton.textContent = `表示: ${this.viewMode === "large" ? "大" : (this.viewMode === "small" ? "小" : "テキスト")}`; + viewButton.title = "表示切替: 大 / 小 / テキスト"; + viewButton.onclick = () => { + this.viewMode = this.viewMode === "large" ? "small" : (this.viewMode === "small" ? "text" : "large"); + this.rememberUiState(); + this.render(); + }; + promptTitleActions.appendChild(viewButton); + const addPart = document.createElement("button"); + addPart.className = "vplm-btn vplm-btn-add"; + addPart.textContent = "+ 追加"; + addPart.onclick = () => this.openPromptModal(); + promptTitleActions.appendChild(addPart); + } + promptTitle.appendChild(promptTitleText); + promptTitle.appendChild(promptTitleActions); + promptPanel.appendChild(promptTitle); + + if (category) { + const searchInput = document.createElement("input"); + searchInput.className = "vplm-input"; + searchInput.placeholder = "選択中カテゴリ内を検索"; + searchInput.value = this.search; + searchInput.oninput = () => { + this.search = searchInput.value; + this.render(); + }; + promptPanel.appendChild(searchInput); + + const partScroll = document.createElement("div"); + partScroll.className = "vplm-prompt-list"; + partScroll.dataset.viewMode = this.viewMode; + category.parts + .filter((part) => { + const q = this.search.trim().toLowerCase(); + if (!q) return true; + return [part.label, part.prompt, part.id].join(" ").toLowerCase().includes(q); + }) + .forEach((part) => { + const item = document.createElement("div"); + item.className = "vplm-prompt-card"; + item.dataset.active = String(part.id === this.selectedPartId); + item.onclick = () => { + this.selectedPartId = part.id; + this.render(); + }; + const thumbWrap = document.createElement("div"); + thumbWrap.className = "vplm-prompt-thumb"; + if (this.viewMode !== "text") { + if (part.thumbnail) { + const img = document.createElement("img"); + img.src = part.thumbnail; + img.alt = part.label || ""; + thumbWrap.appendChild(img); + } else { + const fallback = document.createElement("span"); + fallback.textContent = "サムネなし"; + thumbWrap.appendChild(fallback); + } + item.appendChild(thumbWrap); + } + const body = document.createElement("div"); + body.className = "vplm-prompt-body"; + const text = document.createElement("div"); + const label = document.createElement("div"); + label.className = "vplm-item-title"; + label.textContent = part.label; + const sub = document.createElement("div"); + sub.className = "vplm-item-sub"; + sub.textContent = part.prompt; + text.appendChild(label); + text.appendChild(sub); + const actions = document.createElement("div"); + actions.className = "vplm-item-actions"; + const edit = document.createElement("button"); + edit.className = "vplm-icon-btn vplm-icon-edit"; + edit.appendChild(createIconSvg("edit")); + edit.title = "プロンプトを編集"; + edit.onclick = (event) => { + event.stopPropagation(); + this.selectedPartId = part.id; + this.openPromptModal(part); + }; + const del = document.createElement("button"); + del.className = "vplm-icon-btn vplm-icon-delete vplm-icon-btn-danger"; + del.appendChild(createIconSvg("delete")); + del.title = "プロンプトを削除"; + del.onclick = (event) => { + event.stopPropagation(); + this.deletePart(part.id); + }; + actions.appendChild(edit); + actions.appendChild(del); + body.appendChild(text); + body.appendChild(actions); + item.appendChild(body); + partScroll.appendChild(item); + }); + if (!partScroll.childElementCount) { + const empty = document.createElement("div"); + empty.className = "vplm-empty"; + empty.textContent = this.search ? "一致するプロンプトはありません。" : "プロンプトはまだありません。"; + partScroll.appendChild(empty); + } + promptPanel.appendChild(partScroll); + } else { + const empty = document.createElement("div"); + empty.className = "vplm-empty"; + empty.textContent = "先にカテゴリを作成または選択してください。"; + promptPanel.appendChild(empty); + } + el.appendChild(promptPanel); + } +} + +app.registerExtension({ + name: "ComfyUI.VisualPromptLibrary.Manager", + setup() { + const manager = new VisualPromptLibraryManager(); + app.VisualPromptLibraryManager = manager; + + if (app.extensionManager?.registerSidebarTab) { + app.extensionManager.registerSidebarTab({ + id: "VisualPromptLibrary.Manager", + icon: "pi pi-images", + title: "ビジュアルプロンプトライブラリ", + tooltip: "ビジュアルプロンプトライブラリ", + type: "custom", + render: (el) => { + el.appendChild(manager.element); + manager.refreshLibraries(); + }, + }); + } else { + console.warn("VisualPromptLibrary: registerSidebarTab is not available in this ComfyUI build."); + } + }, +}); diff --git a/js/visual_prompt_picker.js b/js/visual_prompt_picker.js new file mode 100644 index 0000000..6580f04 --- /dev/null +++ b/js/visual_prompt_picker.js @@ -0,0 +1,1614 @@ +import { app } from "../../scripts/app.js"; + +const STYLE_ID = "visual-prompt-picker-style"; +const VPL_API_ROOT = "/visual_prompt_library"; + +function ensureStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + .vpl-root { + position: relative; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + height: 620px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + color: #f5f5f5; + background: #101113; + border: 1px solid #2c2f34; + border-radius: 8px; + font-family: Arial, sans-serif; + overflow: hidden; + } + .vpl-root[data-compact="true"] .vpl-header { + display: grid; + grid-template-columns: 1fr; + } + .vpl-root[data-compact="true"] .vpl-actions { + justify-content: flex-start; + } + .vpl-root[data-compact="true"] .vpl-search-row { + grid-template-columns: 1fr auto; + } + .vpl-root[data-compact="true"] .vpl-grid { + grid-template-columns: 1fr; + } + .vpl-root[data-compact="true"] .vpl-card { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + min-height: 58px; + } + .vpl-root[data-compact="true"] .vpl-thumb { + height: 58px; + min-width: 58px; + } + .vpl-root[data-compact="true"] .vpl-card-body { + min-height: 58px; + border-top: none; + justify-content: center; + } + .vpl-root[data-compact="true"] .vpl-placeholder { + width: 42px; + height: 42px; + font-size: 0; + } + .vpl-root[data-view-mode="small"] .vpl-grid { + grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr)); + } + .vpl-root[data-view-mode="small"] .vpl-card { + min-height: 236px; + } + .vpl-root[data-view-mode="small"] .vpl-thumb { + height: clamp(156px, 22vw, 196px); + } + .vpl-root[data-view-mode="small"] .vpl-card-body { + min-height: 42px; + } + .vpl-root[data-view-mode="small"] .vpl-placeholder { + width: 76px; + height: 76px; + } + .vpl-root[data-view-mode="text"] .vpl-thumb { + display: none; + } + .vpl-root[data-view-mode="text"] .vpl-grid { + grid-template-columns: 1fr; + } + .vpl-root[data-view-mode="text"] .vpl-card { + min-height: 34px; + display: block; + } + .vpl-root[data-view-mode="text"] .vpl-card-body { + min-height: 34px; + padding: 0 10px; + border-top: none; + justify-content: center; + } + .vpl-root[data-view-mode="text"] .vpl-label { + line-height: 34px; + } + .vpl-root[data-view-mode="text"] .vpl-card-meta { + display: none; + } + .vpl-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid #2c2f34; + } + .vpl-title { + font-size: 13px; + font-weight: 700; + min-width: 0; + } + .vpl-clear { + border: 1px solid #3b4048; + background: #1c1f24; + color: #f5f5f5; + border-radius: 5px; + padding: 5px 8px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; + max-width: 100%; + } + .vpl-root[style*="width: 1"] .vpl-clear { + padding-left: 5px; + padding-right: 5px; + } + .vpl-clear:hover { + background: #252a31; + } + .vpl-clear:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .vpl-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + justify-content: flex-end; + min-width: 0; + } + .vpl-action-group { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + padding-left: 7px; + border-left: 1px solid #2c2f34; + } + .vpl-action-group:first-child { + padding-left: 0; + border-left: none; + } + .vpl-tool-panel { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + padding: 7px; + border: 1px solid #2c2f34; + border-radius: 6px; + background: #15181d; + flex: 0 0 auto; + } + .vpl-library-select, + .vpl-library-name { + box-sizing: border-box; + height: 26px; + min-width: 0; + border: 1px solid #3b4048; + border-radius: 5px; + padding: 4px 7px; + color: #f5f5f5; + background: #101318; + font-size: 11px; + outline: none; + } + .vpl-library-select { + width: min(180px, 100%); + } + .vpl-library-name { + width: 110px; + } + .vpl-add-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + padding: 8px; + border: 1px solid #2c2f34; + border-radius: 7px; + background: #15181d; + } + .vpl-add-panel textarea, + .vpl-add-panel input, + .vpl-add-panel select { + box-sizing: border-box; + width: 100%; + border: 1px solid #3b4048; + border-radius: 5px; + padding: 5px 7px; + color: #f5f5f5; + background: #101318; + font-size: 11px; + outline: none; + } + .vpl-add-panel textarea { + min-height: 44px; + resize: vertical; + grid-column: 1 / -1; + } + .vpl-add-panel .vpl-add-buttons { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 6px; + } + .vpl-add-panel input[name="category_label"] { + grid-column: 1 / -1; + } + .vpl-add-panel .vpl-wide { + grid-column: 1 / -1; + } + .vpl-file-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: center; + } + .vpl-file-preview { + width: 42px; + height: 42px; + border: 1px solid #3b4048; + border-radius: 5px; + background: #101318; + object-fit: contain; + } + .vpl-overlay { + position: absolute; + inset: 10px; + z-index: 20; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 38px; + background: rgba(0,0,0,0.54); + border-radius: 8px; + } + .vpl-modal { + width: min(560px, calc(100% - 24px)); + max-height: calc(100% - 48px); + overflow-y: auto; + border: 1px solid #3b4048; + border-radius: 8px; + background: #15181d; + box-shadow: 0 14px 34px rgba(0,0,0,0.45); + padding: 10px; + } + .vpl-modal-title { + font-size: 13px; + font-weight: 700; + margin-bottom: 8px; + } + .vpl-categories { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + flex: 1 1 auto; + max-height: 54px; + overflow-y: auto; + padding-right: 3px; + } + .vpl-category-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 0 0 auto; + } + .vpl-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 6px; + align-items: center; + flex: 0 0 auto; + } + .vpl-search-input { + box-sizing: border-box; + min-width: 0; + width: 100%; + height: 28px; + border: 1px solid #3b4048; + border-radius: 5px; + padding: 5px 8px; + color: #f5f5f5; + background: #101318; + font-size: 11px; + outline: none; + } + .vpl-search-input:focus { + border-color: #4e8cc4; + box-shadow: 0 0 0 1px rgba(78, 140, 196, 0.45); + } + .vpl-tab { + flex: 0 0 auto; + border: 1px solid #3b4048; + background: #191c20; + color: #d8dbe0; + border-radius: 5px; + padding: 4px 6px; + font-size: 11px; + cursor: default; + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + } + .vpl-tab[data-active="true"] { + background: #2e5f8f; + border-color: #4e8cc4; + color: #ffffff; + } + .vpl-tab input { + margin: 0; + } + .vpl-tab-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .vpl-tab-delete { + width: 18px; + height: 18px; + border: 1px solid rgba(255,255,255,0.18); + border-radius: 4px; + background: rgba(10,12,14,0.42); + color: #ffffff; + cursor: pointer; + font-size: 14px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + } + .vpl-tab-delete:hover { + background: rgba(92,34,34,0.75); + } + .vpl-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); + grid-auto-rows: max-content; + gap: 8px; + overflow-y: auto; + padding-right: 4px; + flex: 1 1 auto; + min-height: 0; + align-content: start; + } + .vpl-card { + position: relative; + min-width: 0; + border: 1px solid #333943; + background: #171a1f; + border-radius: 7px; + overflow: hidden; + cursor: pointer; + user-select: none; + display: flex; + flex-direction: column; + min-height: 300px; + } + .vpl-card-tool { + position: absolute; + top: 6px; + width: 24px; + height: 24px; + border: 1px solid rgba(255,255,255,0.22); + border-radius: 5px; + background: rgba(10,12,14,0.76); + color: #ffffff; + cursor: pointer; + font-size: 13px; + line-height: 1; + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + } + .vpl-edit-card { + right: 36px; + } + .vpl-delete-card { + right: 7px; + font-size: 17px; + } + .vpl-card:hover .vpl-card-tool { + opacity: 1; + } + .vpl-card[data-selected="true"] { + border-color: #68b3ff; + box-shadow: 0 0 0 2px rgba(104, 179, 255, 0.25); + background: #1d2732; + } + .vpl-thumb { + height: clamp(220px, 28vw, 260px); + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + background: + linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.01)), + #22262d; + overflow: hidden; + } + .vpl-thumb img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + .vpl-placeholder { + width: 76px; + height: 76px; + border-radius: 50%; + border: 2px solid rgba(255,255,255,0.35); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 11px; + line-height: 1.25; + color: rgba(255,255,255,0.82); + padding: 6px; + box-sizing: border-box; + } + .vpl-card-body { + padding: 7px 8px 8px; + display: flex; + flex-direction: column; + gap: 4px; + min-height: 34px; + background: #11151b; + border-top: 1px solid rgba(255,255,255,0.05); + flex: 0 0 auto; + } + .vpl-label { + font-size: 12px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .vpl-card-meta { + font-size: 10px; + color: #9fb0c3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .vpl-empty { + border: 1px dashed #333943; + border-radius: 7px; + padding: 16px; + color: #9fb0c3; + font-size: 11px; + text-align: center; + background: #11151b; + } + `; + document.head.appendChild(style); +} + +function parseLibrary(nodeData, node) { + const candidates = [ + node.widgets?.find((w) => w.name === "library_json")?.value, + nodeData?.input?.hidden?.library_json?.[1]?.default, + ]; + + for (const raw of candidates) { + if (!raw) continue; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed?.categories)) return parsed; + } catch (error) { + console.error("VisualPromptPicker: failed to parse library_json", error); + } + } + + return { version: 1, categories: [] }; +} + +function resizeImageFileToDataUrl(file, maxSize = 320) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + reader.onload = () => { + const image = new Image(); + image.onerror = () => reject(new Error("Failed to load selected image.")); + image.onload = () => { + const scale = Math.min(1, maxSize / Math.max(image.width, image.height)); + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, width, height); + resolve(canvas.toDataURL("image/webp", 0.82)); + }; + image.src = reader.result; + }; + reader.readAsDataURL(file); + }); +} + +function normalizePart(category, part) { + return { + id: part.id || part.label || part.prompt, + category: category.id || category.label || "default", + categoryLabel: category.label || category.id || "Default", + label: part.label || part.id || part.prompt || "Untitled", + prompt: part.prompt || "", + negative: part.negative || "", + thumbnail: part.thumbnail || "", + }; +} + +function slugify(value) { + const text = String(value || "") + .normalize("NFKC") + .trim() + .toLowerCase(); + let normalized = ""; + for (const char of text) { + if (/^[a-z0-9_-]$/.test(char) || (char.charCodeAt(0) > 127 && !/[\\/:*?"<>|#%&{}$!`'@+=,;[\]\s]/.test(char))) { + normalized += char; + } else { + normalized += "_"; + } + } + normalized = normalized.replace(/^_+|_+$/g, "").replace(/_+/g, "_"); + if (normalized) return normalized; + + let hash = 0; + const hashText = String(value || "part"); + for (let i = 0; i < hashText.length; i += 1) { + hash = ((hash << 5) - hash + hashText.charCodeAt(i)) | 0; + } + return `part_${Math.abs(hash)}`; +} + +function ensureUniqueId(baseId, exists) { + const base = slugify(baseId || "item"); + let id = base; + let index = 2; + while (exists(id)) { + id = `${base}_${index}`; + index += 1; + } + return id; +} + +function sanitizeLibraryName(value) { + return slugify(value || "default"); +} + +async function fetchJson(url, options = {}) { + const method = String(options.method || "GET").toUpperCase(); + let requestUrl = url; + if (method === "GET") { + const parsed = new URL(url, window.location.origin); + parsed.searchParams.set("_", String(Date.now())); + requestUrl = `${parsed.pathname}${parsed.search}`; + } + const response = await fetch(requestUrl, { cache: "no-store", ...options }); + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + return payload; +} + +function createPickerWidget(node, nodeData) { + ensureStyle(); + + let library = parseLibrary(nodeData, node); + let activeCategory = library.categories[0]?.id || library.categories[0]?.label || ""; + let selected = []; + let showPromptPanel = false; + let showCategoryPanel = false; + let editingPart = null; + let viewMode = "large"; + let searchQuery = ""; + let visibleCategoryIds = new Set(library.categories.map((category) => category.id || category.label)); + let categoryFilterTouched = false; + let refocusSearchAfterRender = false; + let showToolMenu = false; + let libraryList = []; + let sharedLibraryName = "default"; + let libraryStatus = ""; + + const root = document.createElement("div"); + root.className = "vpl-root"; + + function syncRootWidth() { + const width = Math.max(1, Math.floor((node.size?.[0] || root.parentElement?.clientWidth || 320) - 24)); + root.style.width = `${width}px`; + root.style.maxWidth = `${width}px`; + root.dataset.compact = String(width < 260); + } + + let selectedPartsWidget = node.widgets.find((w) => w.name === "selected_parts"); + if (!selectedPartsWidget) { + selectedPartsWidget = node.addWidget("text", "selected_parts", "", () => {}); + } + selectedPartsWidget.type = "hidden"; + selectedPartsWidget.hidden = true; + selectedPartsWidget.serializeValue = function() { return this.value || ""; }; + + let libraryWidget = node.widgets.find((w) => w.name === "library_json"); + if (!libraryWidget) { + const defaultLibrary = nodeData?.input?.hidden?.library_json?.[1]?.default || JSON.stringify({ version: 1, categories: [] }); + libraryWidget = node.addWidget("text", "library_json", defaultLibrary, () => {}); + } + libraryWidget.type = "hidden"; + libraryWidget.hidden = true; + libraryWidget.serializeValue = function() { return this.value || ""; }; + + const previewWidget = node.widgets?.find((w) => w.name === "preview_prompt"); + if (previewWidget) { + previewWidget.serializeValue = function() { return this.value || ""; }; + setTimeout(() => { + if (previewWidget.inputEl) { + previewWidget.inputEl.readOnly = true; + previewWidget.inputEl.placeholder = "選択中のプロンプト断片"; + } + }, 0); + } + + function persistLibrary() { + const payload = JSON.stringify(library); + if (libraryWidget.value !== payload) { + libraryWidget.value = payload; + libraryWidget.callback?.call(libraryWidget, payload); + } + node.setDirtyCanvas(true, false); + } + + function syncHidden() { + if (!selectedPartsWidget) return; + const payload = JSON.stringify({ selected }); + if (selectedPartsWidget.value !== payload) { + selectedPartsWidget.value = payload; + selectedPartsWidget.callback?.call(selectedPartsWidget, payload); + } + node.setDirtyCanvas(true, false); + } + + function restoreFromHidden() { + if (!selectedPartsWidget?.value) return; + try { + const state = JSON.parse(selectedPartsWidget.value); + if (Array.isArray(state.selected)) selected = state.selected; + } catch (error) { + console.error("VisualPromptPicker: failed to restore selected parts", error); + } + } + + function isSelected(part) { + return selected.some((item) => item.id === part.id && item.category === part.category); + } + + function getCategoryId(category) { + return category?.id || category?.label || ""; + } + + function syncVisibleCategories() { + const categoryIds = library.categories.map((category) => getCategoryId(category)).filter(Boolean); + const known = new Set(categoryIds); + visibleCategoryIds = new Set([...visibleCategoryIds].filter((id) => known.has(id))); + } + + function showAllCategories() { + visibleCategoryIds = new Set(library.categories.map((category) => getCategoryId(category)).filter(Boolean)); + } + + function replaceLibrary(nextLibrary, { clearSelection = false } = {}) { + if (!nextLibrary?.categories || !Array.isArray(nextLibrary.categories)) return; + library = { + version: nextLibrary.version || 1, + meta: nextLibrary.meta || {}, + categories: nextLibrary.categories, + }; + if (clearSelection) { + selected = []; + searchQuery = ""; + } + categoryFilterTouched = false; + showAllCategories(); + persistLibrary(); + syncHidden(); + syncPreviewWidget(); + render(); + } + + async function refreshLibraryList() { + try { + const payload = await fetchJson(`${VPL_API_ROOT}/libraries`); + libraryList = Array.isArray(payload.libraries) ? payload.libraries : []; + if (!libraryList.some((item) => item.name === sharedLibraryName) && libraryList[0]?.name) { + sharedLibraryName = libraryList[0].name; + } + render(); + } catch (error) { + console.error("VisualPromptPicker: failed to list shared libraries", error); + libraryStatus = "DB一覧取得失敗"; + render(); + } + } + + async function loadSharedLibrary(name = sharedLibraryName) { + const safeName = sanitizeLibraryName(name); + try { + const payload = await fetchJson(`${VPL_API_ROOT}/libraries/${encodeURIComponent(safeName)}`); + sharedLibraryName = payload.name || safeName; + libraryStatus = `読み込み済み: ${sharedLibraryName}`; + replaceLibrary(payload.library, { clearSelection: true }); + } catch (error) { + console.error("VisualPromptPicker: failed to load shared library", error); + alert(`ライブラリの読み込みに失敗しました: ${error.message}`); + } + } + + async function saveSharedLibrary(name = sharedLibraryName) { + const safeName = sanitizeLibraryName(name); + try { + const payload = await fetchJson(`${VPL_API_ROOT}/libraries/${encodeURIComponent(safeName)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ library }), + }); + sharedLibraryName = payload.name || safeName; + libraryStatus = `Saved: ${sharedLibraryName}`; + await refreshLibraryList(); + } catch (error) { + console.error("VisualPromptPicker: failed to save shared library", error); + alert(`Failed to save library: ${error.message}`); + } + } + + function normalizeSearchText(value) { + return String(value || "").toLowerCase().replace(/[_-]+/g, " ").trim(); + } + + function partMatchesSearch(part, query) { + if (!query) return true; + const haystack = normalizeSearchText([ + part.label, + part.prompt, + part.id, + part.categoryLabel, + ].join(" ")); + return query.split(/\s+/).every((token) => haystack.includes(token)); + } + + function getVisibleParts() { + syncVisibleCategories(); + const query = normalizeSearchText(searchQuery); + const sourceCategories = library.categories.filter((category) => visibleCategoryIds.has(getCategoryId(category))); + const parts = []; + + sourceCategories.forEach((category) => { + (category?.parts || []).forEach((part) => { + const normalized = normalizePart(category, part); + if (partMatchesSearch(normalized, query)) parts.push(normalized); + }); + }); + + return parts; + } + + function getDefaultCategoryId() { + syncVisibleCategories(); + const visible = library.categories.find((category) => visibleCategoryIds.has(getCategoryId(category))); + return getCategoryId(visible || library.categories[0]); + } + + function getDelimiter() { + return node.widgets?.find((w) => w.name === "delimiter")?.value ?? ", "; + } + + function buildPromptPreview() { + return selected + .map((item) => item.prompt || "") + .filter(Boolean) + .join(getDelimiter()); + } + + function syncPreviewWidget() { + const previewWidget = node.widgets?.find((w) => w.name === "preview_prompt"); + if (!previewWidget) return; + const text = buildPromptPreview(); + if (previewWidget.value !== text) { + previewWidget.value = text; + previewWidget.callback?.call(previewWidget, text); + } + } + + function togglePart(part) { + if (isSelected(part)) { + selected = selected.filter((item) => !(item.id === part.id && item.category === part.category)); + } else { + selected = [...selected, part]; + } + syncHidden(); + syncPreviewWidget(); + render(); + } + + function deletePart(part) { + const category = library.categories.find((item) => (item.id || item.label) === part.category); + if (!category || !Array.isArray(category.parts)) return; + category.parts = category.parts.filter((item) => item.id !== part.id); + selected = selected.filter((item) => !(item.id === part.id && item.category === part.category)); + persistLibrary(); + syncHidden(); + syncPreviewWidget(); + render(); + } + + function openPromptEditor(part = null) { + editingPart = part ? { ...part } : null; + showPromptPanel = true; + showCategoryPanel = false; + render(); + } + + function addCategoryFromForm(form) { + const data = new FormData(form); + const label = String(data.get("category_label") || "").trim(); + if (!label) return; + + const id = ensureUniqueId(label, (candidate) => library.categories.some((category) => (category.id || category.label) === candidate)); + + library.categories.push({ id, label, select_mode: "multi", parts: [] }); + activeCategory = id; + visibleCategoryIds.add(id); + persistLibrary(); + showCategoryPanel = false; + render(); + } + + function deleteCategory(categoryId) { + const category = library.categories.find((item) => getCategoryId(item) === categoryId); + if (!category) return; + const label = category.label || category.id || "this category"; + if (!confirm(`Delete category "${label}" and all prompt parts in it?`)) return; + + library.categories = library.categories.filter((item) => getCategoryId(item) !== categoryId); + selected = selected.filter((item) => item.category !== categoryId); + visibleCategoryIds.delete(categoryId); + categoryFilterTouched = true; + activeCategory = library.categories[0]?.id || library.categories[0]?.label || ""; + syncVisibleCategories(); + persistLibrary(); + syncHidden(); + syncPreviewWidget(); + render(); + } + + function addPartFromForm(form) { + const data = new FormData(form); + const existingCategoryId = data.get("category") || getDefaultCategoryId(); + const label = String(data.get("label") || "").trim(); + const prompt = String(data.get("prompt") || "").trim(); + const thumbnailFileData = String(data.get("thumbnail_file_data") || "").trim(); + const thumbnailManual = String(data.get("thumbnail_manual") || "").trim(); + const thumbnail = thumbnailFileData || thumbnailManual; + + if (!label || !prompt) return; + + let category = null; + category = library.categories.find((item) => (item.id || item.label) === existingCategoryId) + || library.categories[0]; + + if (!category) return; + if (!Array.isArray(category.parts)) category.parts = []; + + if (editingPart) { + const oldCategory = library.categories.find((item) => (item.id || item.label) === editingPart.category); + const oldPart = oldCategory?.parts?.find((part) => part.id === editingPart.id); + const thumbnail = thumbnailFileData || thumbnailManual || oldPart?.thumbnail || ""; + + if (oldCategory && oldPart && oldCategory !== category) { + oldCategory.parts = oldCategory.parts.filter((part) => part.id !== editingPart.id); + category.parts.push({ ...oldPart, category: undefined }); + } + + const target = category.parts.find((part) => part.id === editingPart.id) || oldPart; + if (target) { + target.label = label; + target.prompt = prompt; + target.thumbnail = thumbnail; + } + + selected = selected.map((item) => { + if (item.id !== editingPart.id || item.category !== editingPart.category) return item; + return normalizePart(category, target || { id: editingPart.id, label, prompt, thumbnail }); + }); + + activeCategory = category.id || category.label || activeCategory; + persistLibrary(); + syncHidden(); + syncPreviewWidget(); + showPromptPanel = false; + editingPart = null; + render(); + return; + } + + const idBase = slugify(label); + let id = idBase; + let index = 2; + while (category.parts.some((part) => part.id === id)) { + id = `${idBase}_${index}`; + index += 1; + } + + category.parts.push({ id, label, prompt, thumbnail }); + persistLibrary(); + showPromptPanel = false; + editingPart = null; + render(); + } + + function normalizeImportedLibrary(raw) { + const categories = Array.isArray(raw?.categories) ? raw.categories : (Array.isArray(raw) ? raw : []); + return { + version: 1, + categories: categories + .map((category) => { + const label = String(category?.label || category?.id || "").trim(); + const id = slugify(category?.id || label); + const parts = Array.isArray(category?.parts) ? category.parts : []; + if (!label || !id) return null; + return { + id, + label, + select_mode: category?.select_mode || "multi", + parts: parts + .map((part) => { + const prompt = String(part?.prompt || "").trim(); + const label = String(part?.label || part?.id || prompt || "").trim(); + if (!label || !prompt) return null; + return { + id: slugify(part?.id || label || prompt), + label, + prompt, + negative: part?.negative || "", + thumbnail: part?.thumbnail || "", + }; + }) + .filter(Boolean), + }; + }) + .filter((category) => category && category.parts.length), + }; + } + + function mergeImportedLibrary(imported) { + const importLibrary = normalizeImportedLibrary(imported); + let addedCategories = 0; + let addedParts = 0; + + importLibrary.categories.forEach((incomingCategory) => { + let targetCategory = library.categories.find((category) => ( + (category.id || category.label) === incomingCategory.id + || String(category.label || "").toLowerCase() === incomingCategory.label.toLowerCase() + )); + + if (!targetCategory) { + const categoryId = ensureUniqueId(incomingCategory.id || incomingCategory.label, (candidate) => ( + library.categories.some((category) => (category.id || category.label) === candidate) + )); + targetCategory = { + id: categoryId, + label: incomingCategory.label, + select_mode: incomingCategory.select_mode || "multi", + parts: [], + }; + library.categories.push(targetCategory); + visibleCategoryIds.add(categoryId); + addedCategories += 1; + } + + if (!Array.isArray(targetCategory.parts)) targetCategory.parts = []; + + incomingCategory.parts.forEach((incomingPart) => { + const duplicate = targetCategory.parts.some((part) => ( + String(part.prompt || "").trim() === incomingPart.prompt + || String(part.label || "").trim() === incomingPart.label + )); + if (duplicate) return; + + const partId = ensureUniqueId(incomingPart.id || incomingPart.label || incomingPart.prompt, (candidate) => ( + targetCategory.parts.some((part) => part.id === candidate) + )); + targetCategory.parts.push({ ...incomingPart, id: partId }); + addedParts += 1; + }); + }); + + return { addedCategories, addedParts }; + } + + function exportLibraryJson() { + const payload = { + ...library, + version: library.version || 1, + exported_at: new Date().toISOString(), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + anchor.href = url; + anchor.download = `visual_prompt_library_${stamp}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } + + function importLibraryJsonFile(file) { + if (!file) return; + const reader = new FileReader(); + reader.onerror = () => alert("Failed to read JSON file."); + reader.onload = () => { + try { + const parsed = JSON.parse(String(reader.result || "")); + const result = mergeImportedLibrary(parsed); + if (!result.addedCategories && !result.addedParts) { + alert("新しく追加できるプロンプトはありませんでした。"); + return; + } + persistLibrary(); + activeCategory = library.categories[0]?.id || library.categories[0]?.label || activeCategory; + render(); + alert(`インポートしました: プロンプト ${result.addedParts} 件 / カテゴリ ${result.addedCategories} 件`); + } catch (error) { + console.error("VisualPromptPicker: failed to import library JSON", error); + alert("Visual Prompt Library JSONとして読み込めません。"); + } + }; + reader.readAsText(file); + } + + function renderUnsafe() { + if (libraryWidget?.value) { + try { + const parsed = JSON.parse(libraryWidget.value); + if (Array.isArray(parsed?.categories)) { + const beforeIds = library.categories.map((category) => getCategoryId(category)).join("\n"); + const afterIds = parsed.categories.map((category) => getCategoryId(category)).join("\n"); + library = parsed; + if (!categoryFilterTouched && beforeIds !== afterIds) { + showAllCategories(); + } + } + } catch (_) { + // Keep the last valid library. + } + } + root.innerHTML = ""; + root.dataset.viewMode = viewMode; + + const header = document.createElement("div"); + header.className = "vpl-header"; + + const title = document.createElement("div"); + title.className = "vpl-title"; + title.textContent = "ビジュアルプロンプト選択"; + + const clear = document.createElement("button"); + clear.className = "vpl-clear"; + clear.textContent = "選択クリア"; + clear.onclick = (event) => { + event.stopPropagation(); + selected = []; + syncHidden(); + syncPreviewWidget(); + render(); + }; + + const thumbToggle = document.createElement("button"); + thumbToggle.className = "vpl-clear"; + thumbToggle.textContent = `表示: ${viewMode === "large" ? "大" : (viewMode === "small" ? "小" : "テキスト")}`; + thumbToggle.title = "表示切替: 大 / 小 / テキスト"; + thumbToggle.onclick = (event) => { + event.stopPropagation(); + viewMode = viewMode === "large" ? "small" : (viewMode === "small" ? "text" : "large"); + render(); + }; + + header.appendChild(title); + const actions = document.createElement("div"); + actions.className = "vpl-actions"; + + const fileActions = document.createElement("div"); + fileActions.className = "vpl-action-group"; + const toolButton = document.createElement("button"); + toolButton.className = "vpl-clear"; + toolButton.textContent = "\u{1F527}"; + toolButton.title = "DB選択"; + toolButton.onclick = (event) => { + event.stopPropagation(); + showToolMenu = !showToolMenu; + if (showToolMenu) refreshLibraryList(); + render(); + }; + fileActions.appendChild(toolButton); + + actions.appendChild(fileActions); + header.appendChild(actions); + root.appendChild(header); + + if (showToolMenu) { + const toolPanel = document.createElement("div"); + toolPanel.className = "vpl-tool-panel"; + toolPanel.onclick = (event) => event.stopPropagation(); + + const librarySelect = document.createElement("select"); + librarySelect.className = "vpl-library-select"; + librarySelect.title = "共有DB"; + if (!libraryList.length) { + const option = document.createElement("option"); + option.value = sharedLibraryName; + option.textContent = `${sharedLibraryName} (local)`; + librarySelect.appendChild(option); + } else { + libraryList.forEach((item) => { + const option = document.createElement("option"); + option.value = item.name; + option.textContent = `${item.label || item.name} (${item.categories}/${item.parts})`; + option.selected = item.name === sharedLibraryName; + librarySelect.appendChild(option); + }); + } + librarySelect.onchange = (event) => { + event.stopPropagation(); + sharedLibraryName = librarySelect.value || "default"; + loadSharedLibrary(sharedLibraryName); + }; + + toolPanel.appendChild(librarySelect); + if (libraryStatus) { + const status = document.createElement("span"); + status.className = "vpl-card-meta"; + status.textContent = libraryStatus; + toolPanel.appendChild(status); + } + root.appendChild(toolPanel); + } + + if (showCategoryPanel) { + const overlay = document.createElement("div"); + overlay.className = "vpl-overlay"; + overlay.onclick = (event) => { + if (event.target === overlay) { + showCategoryPanel = false; + render(); + } + }; + + const modal = document.createElement("div"); + modal.className = "vpl-modal"; + + const modalTitle = document.createElement("div"); + modalTitle.className = "vpl-modal-title"; + modalTitle.textContent = "カテゴリ"; + + const form = document.createElement("form"); + form.className = "vpl-add-panel"; + form.onsubmit = (event) => { + event.preventDefault(); + event.stopPropagation(); + addCategoryFromForm(form); + }; + + const labelInput = document.createElement("input"); + labelInput.name = "category_label"; + labelInput.placeholder = "カテゴリ名"; + + const buttons = document.createElement("div"); + buttons.className = "vpl-add-buttons"; + + const save = document.createElement("button"); + save.type = "submit"; + save.className = "vpl-clear"; + save.textContent = "カテゴリを保存"; + + buttons.appendChild(save); + form.appendChild(labelInput); + form.appendChild(buttons); + modal.appendChild(modalTitle); + modal.appendChild(form); + overlay.appendChild(modal); + root.appendChild(overlay); + } + + if (showPromptPanel) { + const overlay = document.createElement("div"); + overlay.className = "vpl-overlay"; + overlay.onclick = (event) => { + if (event.target === overlay) { + showPromptPanel = false; + editingPart = null; + render(); + } + }; + + const modal = document.createElement("div"); + modal.className = "vpl-modal"; + + const modalTitle = document.createElement("div"); + modalTitle.className = "vpl-modal-title"; + modalTitle.textContent = editingPart ? "プロンプトを編集" : "プロンプトを追加"; + + const form = document.createElement("form"); + form.className = "vpl-add-panel"; + form.onsubmit = (event) => { + event.preventDefault(); + event.stopPropagation(); + addPartFromForm(form); + }; + + const categorySelect = document.createElement("select"); + categorySelect.name = "category"; + const defaultCategoryId = editingPart?.category || getDefaultCategoryId(); + library.categories.forEach((category) => { + const option = document.createElement("option"); + option.value = category.id || category.label; + option.textContent = category.label || category.id; + option.selected = option.value === defaultCategoryId; + categorySelect.appendChild(option); + }); + + const labelInput = document.createElement("input"); + labelInput.name = "label"; + labelInput.placeholder = "Label"; + labelInput.value = editingPart?.label || ""; + + const fileRow = document.createElement("div"); + fileRow.className = "vpl-file-row"; + + const thumbnailFile = document.createElement("input"); + thumbnailFile.type = "file"; + thumbnailFile.accept = "image/*"; + thumbnailFile.title = "ローカル画像を選択"; + + const thumbnailFileData = document.createElement("input"); + thumbnailFileData.type = "hidden"; + thumbnailFileData.name = "thumbnail_file_data"; + + const thumbnailPreview = document.createElement("img"); + thumbnailPreview.className = "vpl-file-preview"; + thumbnailPreview.alt = ""; + if (editingPart?.thumbnail) { + thumbnailPreview.src = editingPart.thumbnail; + } + + thumbnailFile.onchange = async () => { + const file = thumbnailFile.files?.[0]; + if (!file) { + thumbnailFileData.value = ""; + thumbnailPreview.removeAttribute("src"); + return; + } + try { + const dataUrl = await resizeImageFileToDataUrl(file); + thumbnailFileData.value = dataUrl; + thumbnailPreview.src = dataUrl; + } catch (error) { + console.error("VisualPromptPicker: failed to read thumbnail file", error); + thumbnailFileData.value = ""; + thumbnailPreview.removeAttribute("src"); + } + }; + + fileRow.appendChild(thumbnailFile); + fileRow.appendChild(thumbnailPreview); + + const thumbnailManual = document.createElement("input"); + thumbnailManual.name = "thumbnail_manual"; + thumbnailManual.className = "vpl-wide"; + thumbnailManual.placeholder = "任意のサムネイルパス"; + thumbnailManual.value = editingPart?.thumbnail?.startsWith("data:") ? "" : (editingPart?.thumbnail || ""); + + const promptInput = document.createElement("textarea"); + promptInput.name = "prompt"; + promptInput.placeholder = "プロンプト断片"; + promptInput.value = editingPart?.prompt || ""; + + const buttons = document.createElement("div"); + buttons.className = "vpl-add-buttons"; + + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "vpl-clear"; + cancel.textContent = "キャンセル"; + cancel.onclick = (event) => { + event.stopPropagation(); + showPromptPanel = false; + editingPart = null; + render(); + }; + + const save = document.createElement("button"); + save.type = "submit"; + save.className = "vpl-clear"; + save.textContent = editingPart ? "更新" : "保存"; + + buttons.appendChild(cancel); + buttons.appendChild(save); + form.appendChild(categorySelect); + form.appendChild(labelInput); + form.appendChild(fileRow); + form.appendChild(thumbnailFileData); + form.appendChild(thumbnailManual); + form.appendChild(promptInput); + form.appendChild(buttons); + modal.appendChild(modalTitle); + modal.appendChild(form); + overlay.appendChild(modal); + root.appendChild(overlay); + } + + const categoryRow = document.createElement("div"); + categoryRow.className = "vpl-category-row"; + + const tabs = document.createElement("div"); + tabs.className = "vpl-categories"; + syncVisibleCategories(); + + library.categories.forEach((category) => { + const categoryId = getCategoryId(category); + const tab = document.createElement("label"); + tab.className = "vpl-tab"; + tab.dataset.active = String(visibleCategoryIds.has(categoryId)); + tab.title = category.label || category.id; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = visibleCategoryIds.has(categoryId); + checkbox.onchange = (event) => { + event.stopPropagation(); + categoryFilterTouched = true; + if (checkbox.checked) { + visibleCategoryIds.add(categoryId); + } else { + visibleCategoryIds.delete(categoryId); + } + render(); + }; + + const label = document.createElement("span"); + label.className = "vpl-tab-label"; + label.textContent = category.label || category.id; + + tab.appendChild(checkbox); + tab.appendChild(label); + tabs.appendChild(tab); + }); + categoryRow.appendChild(tabs); + root.appendChild(categoryRow); + + const searchRow = document.createElement("div"); + searchRow.className = "vpl-search-row"; + + const searchInput = document.createElement("input"); + searchInput.className = "vpl-search-input"; + searchInput.type = "search"; + searchInput.placeholder = "チェック済みカテゴリを検索"; + searchInput.value = searchQuery; + searchInput.oninput = (event) => { + event.stopPropagation(); + searchQuery = searchInput.value; + refocusSearchAfterRender = true; + render(); + }; + searchInput.onclick = (event) => event.stopPropagation(); + searchInput.onkeydown = (event) => event.stopPropagation(); + + const clearSearch = document.createElement("button"); + clearSearch.className = "vpl-clear"; + clearSearch.textContent = "×"; + clearSearch.title = "検索をクリア"; + clearSearch.disabled = !searchQuery; + clearSearch.onclick = (event) => { + event.stopPropagation(); + searchQuery = ""; + refocusSearchAfterRender = true; + render(); + }; + + searchRow.appendChild(searchInput); + searchRow.appendChild(clearSearch); + searchRow.appendChild(thumbToggle); + searchRow.appendChild(clear); + root.appendChild(searchRow); + + const grid = document.createElement("div"); + grid.className = "vpl-grid"; + + const visibleParts = getVisibleParts(); + const showCategoryMeta = visibleCategoryIds.size > 1; + visibleParts.forEach((part) => { + const card = document.createElement("div"); + card.className = "vpl-card"; + card.dataset.selected = String(isSelected(part)); + card.title = `${part.label}\n${part.prompt}\n${part.categoryLabel}`; + card.onclick = (event) => { + event.stopPropagation(); + togglePart(part); + }; + + const thumb = document.createElement("div"); + thumb.className = "vpl-thumb"; + if (part.thumbnail) { + const img = document.createElement("img"); + img.src = part.thumbnail; + img.alt = part.label; + img.onerror = () => { + thumb.innerHTML = ""; + const placeholder = document.createElement("div"); + placeholder.className = "vpl-placeholder"; + placeholder.textContent = part.label; + thumb.appendChild(placeholder); + }; + thumb.appendChild(img); + } else { + const placeholder = document.createElement("div"); + placeholder.className = "vpl-placeholder"; + placeholder.textContent = part.label; + thumb.appendChild(placeholder); + } + + const body = document.createElement("div"); + body.className = "vpl-card-body"; + + const label = document.createElement("div"); + label.className = "vpl-label"; + label.textContent = part.label; + + body.appendChild(label); + if (showCategoryMeta) { + const meta = document.createElement("div"); + meta.className = "vpl-card-meta"; + meta.textContent = part.categoryLabel; + body.appendChild(meta); + } + card.appendChild(thumb); + card.appendChild(body); + grid.appendChild(card); + }); + + if (!visibleParts.length) { + const empty = document.createElement("div"); + empty.className = "vpl-empty"; + empty.textContent = searchQuery ? "一致するプロンプトはありません。" : "カテゴリにチェックを入れるとプロンプトが表示されます。"; + grid.appendChild(empty); + } + + root.appendChild(grid); + + syncRootWidth(); + + if (refocusSearchAfterRender) { + refocusSearchAfterRender = false; + setTimeout(() => { + const nextSearch = root.querySelector(".vpl-search-input"); + if (!nextSearch) return; + nextSearch.focus(); + const length = nextSearch.value.length; + nextSearch.setSelectionRange?.(length, length); + }, 0); + } + } + + function render() { + try { + renderUnsafe(); + } catch (error) { + console.error("VisualPromptPicker: render failed", error); + root.innerHTML = ""; + const errorBox = document.createElement("div"); + errorBox.className = "vpl-empty"; + errorBox.textContent = `表示エラー: ${error?.message || error}`; + root.appendChild(errorBox); + } + } + + restoreFromHidden(); + syncPreviewWidget(); + render(); + setTimeout(() => refreshLibraryList(), 0); + + const delimiterWidget = node.widgets?.find((w) => w.name === "delimiter"); + if (delimiterWidget && !delimiterWidget._vplWrapped) { + const originalCallback = delimiterWidget.callback; + delimiterWidget.callback = function() { + originalCallback?.apply(this, arguments); + syncPreviewWidget(); + render(); + syncHidden(); + }; + delimiterWidget._vplWrapped = true; + } + + const widget = node.addDOMWidget("visual_prompt_picker", "visual_prompt_picker", root, { + getValue() { + return { selected }; + }, + setValue(value) { + if (value?.selected && Array.isArray(value.selected)) { + selected = value.selected; + syncHidden(); + render(); + } + }, + getMinHeight() { + return 630; + }, + hideOnZoom: false, + }); + + widget.computeSize = function(width) { + syncRootWidth(); + return [Math.max(1, width || node.size?.[0] || 640), 630]; + }; + + const resizeObserver = new ResizeObserver(() => syncRootWidth()); + if (root.parentElement) { + resizeObserver.observe(root.parentElement); + } else { + setTimeout(() => { + if (root.parentElement) resizeObserver.observe(root.parentElement); + syncRootWidth(); + }, 0); + } + const previousOnResize = node.onResize; + node.onResize = function() { + const result = previousOnResize?.apply(this, arguments); + syncRootWidth(); + return result; + }; + widget.onRemove = () => resizeObserver.disconnect(); + syncRootWidth(); + + return widget; +} + +function clampPickerNodeSize(node) { + const currentWidth = node.size?.[0] || 0; + const currentHeight = node.size?.[1] || 0; + const width = Math.max(180, Math.min(currentWidth || 700, 980)); + const height = Math.max(820, Math.min(currentHeight || 880, 1100)); + + if (currentWidth !== width || currentHeight !== height) { + node.setSize([width, height]); + } +} + +app.registerExtension({ + name: "ComfyUI.VisualPromptLibrary", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "VisualPromptPicker") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const result = onNodeCreated?.apply(this, arguments); + const node = this; + + const hiddenNames = new Set(["selected_parts", "library_json", "input_images_json", "include_negative", "prefix", "suffix"]); + node.widgets?.forEach((widget) => { + if (hiddenNames.has(widget.name)) { + widget.type = "hidden"; + widget.hidden = true; + widget.options = widget.options || {}; + widget.options.visible = false; + } + }); + + createPickerWidget(node, nodeData); + clampPickerNodeSize(node); + + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function() { + const result = onConfigure?.apply(this, arguments); + setTimeout(() => clampPickerNodeSize(this), 0); + setTimeout(() => clampPickerNodeSize(this), 100); + return result; + }; + }, +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b784e6e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "visual-prompt-library" +description = "Thumbnail-based visual prompt part picker and library manager for ComfyUI." +version = "0.1.0" +license = { file = "LICENSE" } +requires-python = ">=3.10" +dependencies = [] +classifiers = [ + "Operating System :: OS Independent", +] + +[project.urls] +Repository = "https://github.com/kazuya-bros/ComfyUI-VisualPromptLibrary" +"Bug Tracker" = "https://github.com/kazuya-bros/ComfyUI-VisualPromptLibrary/issues" + +[tool.comfy] +PublisherId = "kazuya-bros" +DisplayName = "Visual Prompt Library" diff --git a/visual_prompt_picker.py b/visual_prompt_picker.py new file mode 100644 index 0000000..9718f8d --- /dev/null +++ b/visual_prompt_picker.py @@ -0,0 +1,273 @@ +import hashlib +import json +import os +from pathlib import Path +from urllib.parse import urlencode + +from aiohttp import web +from server import PromptServer + +try: + import folder_paths +except Exception: + folder_paths = None + + +NODE_DIR = Path(__file__).resolve().parent +DEFAULT_LIBRARY_PATH = NODE_DIR / "data" / "expression_parts.json" +LIBRARIES_DIR = NODE_DIR / "data" / "libraries" +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} + + +def load_default_library(): + try: + return json.loads(DEFAULT_LIBRARY_PATH.read_text(encoding="utf-8")) + except Exception: + return {"categories": []} + + +DEFAULT_LIBRARY = load_default_library() + + +def safe_library_name(name): + cleaned = "".join(c for c in str(name or "default") if c.isalnum() or c in ("-", "_")).strip() + return cleaned or "default" + + +def library_path(name): + LIBRARIES_DIR.mkdir(parents=True, exist_ok=True) + return LIBRARIES_DIR / f"{safe_library_name(name)}.json" + + +def get_output_directory(): + if folder_paths is not None: + try: + return Path(folder_paths.get_output_directory()).resolve() + except Exception: + pass + return (NODE_DIR.parent.parent / "output").resolve() + + +def ensure_default_library_file(): + path = library_path("default") + if not path.exists(): + path.write_text(json.dumps(DEFAULT_LIBRARY, ensure_ascii=False, indent=2), encoding="utf-8") + + +def read_library_file(name): + path = library_path(name) + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def write_library_file(name, payload): + categories = payload.get("categories") if isinstance(payload, dict) else None + if not isinstance(categories, list): + raise ValueError("library JSON must contain a categories array") + + normalized = { + "version": payload.get("version", 1), + "meta": payload.get("meta", {}), + "categories": categories, + } + path = library_path(name) + path.write_text(json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8") + return normalized + + +def list_library_files(): + ensure_default_library_file() + items = [] + for path in sorted(LIBRARIES_DIR.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + data = {} + categories = data.get("categories") if isinstance(data, dict) else [] + parts_count = 0 + if isinstance(categories, list): + for category in categories: + if isinstance(category, dict) and isinstance(category.get("parts"), list): + parts_count += len(category["parts"]) + items.append( + { + "name": path.stem, + "label": data.get("meta", {}).get("title") or path.stem, + "categories": len(categories) if isinstance(categories, list) else 0, + "parts": parts_count, + } + ) + return items + + +def list_recent_output_images(limit=80): + output_dir = get_output_directory() + if not output_dir.exists(): + return [] + + files = [] + for path in output_dir.rglob("*"): + if not path.is_file() or path.suffix.lower() not in IMAGE_EXTENSIONS: + continue + try: + stat = path.stat() + rel = path.relative_to(output_dir) + except Exception: + continue + + subfolder = "" if rel.parent == Path(".") else rel.parent.as_posix() + query = { + "filename": rel.name, + "type": "output", + } + if subfolder: + query["subfolder"] = subfolder + + files.append( + { + "filename": rel.name, + "subfolder": subfolder, + "mtime": stat.st_mtime, + "size": stat.st_size, + "url": f"/view?{urlencode(query)}", + } + ) + + files.sort(key=lambda item: item["mtime"], reverse=True) + return files[: max(1, min(int(limit or 80), 200))] + + +@PromptServer.instance.routes.get("/visual_prompt_library/libraries") +async def vpl_list_libraries(_request): + return web.json_response({"libraries": list_library_files()}) + + +@PromptServer.instance.routes.get("/visual_prompt_library/recent_outputs") +async def vpl_recent_outputs(request): + limit = request.query.get("limit", "80") + try: + images = list_recent_output_images(limit) + except Exception as error: + return web.json_response({"error": str(error)}, status=400) + return web.json_response({"images": images, "output_dir": str(get_output_directory())}) + + +@PromptServer.instance.routes.post("/visual_prompt_library/open_output_folder") +async def vpl_open_output_folder(_request): + output_dir = get_output_directory() + try: + if os.name == "nt": + os.startfile(str(output_dir)) + else: + import subprocess + + subprocess.Popen(["xdg-open", str(output_dir)]) + except Exception as error: + return web.json_response({"error": str(error)}, status=400) + return web.json_response({"opened": True, "output_dir": str(output_dir)}) + + +@PromptServer.instance.routes.get("/visual_prompt_library/libraries/{name}") +async def vpl_get_library(request): + name = safe_library_name(request.match_info.get("name")) + data = read_library_file(name) + if data is None: + return web.json_response({"error": "library not found"}, status=404) + return web.json_response({"name": name, "library": data}) + + +@PromptServer.instance.routes.post("/visual_prompt_library/libraries/{name}") +async def vpl_save_library(request): + name = safe_library_name(request.match_info.get("name")) + try: + payload = await request.json() + library = payload.get("library", payload) if isinstance(payload, dict) else payload + saved = write_library_file(name, library) + except Exception as error: + return web.json_response({"error": str(error)}, status=400) + return web.json_response({"name": name, "library": saved}) + + +@PromptServer.instance.routes.delete("/visual_prompt_library/libraries/{name}") +async def vpl_delete_library(request): + name = safe_library_name(request.match_info.get("name")) + if name == "default": + return web.json_response({"error": "default library cannot be deleted"}, status=400) + path = library_path(name) + if not path.exists(): + return web.json_response({"error": "library not found"}, status=404) + try: + path.unlink() + except Exception as error: + return web.json_response({"error": str(error)}, status=400) + return web.json_response({"name": name, "deleted": True}) + + +class VisualPromptPicker: + DESCRIPTION = "Thumbnail-driven prompt part picker. Click visual cards to emit selected prompt fragments." + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "delimiter": ("STRING", {"default": ", ", "multiline": False}), + "preview_prompt": ("STRING", {"default": "", "multiline": True}), + }, + "hidden": { + "selected_parts": ("STRING", {"default": ""}), + "library_json": ("STRING", {"default": json.dumps(DEFAULT_LIBRARY, ensure_ascii=False)}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt",) + FUNCTION = "build_prompt" + CATEGORY = "prompt/visual" + + @classmethod + def IS_CHANGED(cls, delimiter, preview_prompt="", **kwargs): + payload = json.dumps( + { + "delimiter": delimiter, + "selected_parts": kwargs.get("selected_parts", ""), + "library_json": kwargs.get("library_json", ""), + }, + ensure_ascii=False, + sort_keys=True, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def build_prompt(self, delimiter, preview_prompt="", **kwargs): + selected_raw = kwargs.get("selected_parts") or "" + + try: + state = json.loads(selected_raw) if selected_raw else {} + except json.JSONDecodeError: + state = {} + + selected = state.get("selected", []) + if not isinstance(selected, list): + selected = [] + + fragments = [] + + for item in selected: + if not isinstance(item, dict): + continue + + prompt = str(item.get("prompt", "")).strip() + if prompt: + fragments.append(prompt) + + prompt = delimiter.join([p for p in fragments if p]) + return (prompt,) + + +NODE_CLASS_MAPPINGS = { + "VisualPromptPicker": VisualPromptPicker, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "VisualPromptPicker": "Visual Prompt Picker", +}