Initial public release prep

This commit is contained in:
kazuya_bros
2026-06-28 15:40:34 +09:00
commit c037483f95
12 changed files with 3773 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
data/libraries/
docs/dev/
*.psd
*.clip
*.kra
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
data/libraries/
.DS_Store
Thumbs.db
+22
View File
@@ -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.
+115
View File
@@ -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
+5
View File
@@ -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"]
+53
View File
@@ -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": ""
}
]
}
]
}
+132
View File
@@ -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": ""
}
]
}
]
}
+17
View File
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"
+273
View File
@@ -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",
}