Add Style Prompt Library node package
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
output/
|
||||
*.egg-info/
|
||||
@@ -1,2 +1,234 @@
|
||||
# ComfyUI-StylePromptLibrary
|
||||
A single node that lets you pick an image style from a large, **categorized** library and outputs its descriptive text — ready to wire into any LLM / prompt node.
|
||||
|
||||
ComfyUI-StylePromptLibrary is a ComfyUI custom node pack for selecting reusable image style descriptions from a categorized JSON library. It outputs clean style text that can be connected to an LLM prompt node, a prompt-building workflow, or directly into text conditioning when useful.
|
||||
|
||||
The pack includes a cascading style picker, live search, a read-only preview panel, style strength controls, and a companion node for blending multiple style guides.
|
||||
|
||||
## Nodes
|
||||
|
||||
| Node | Category | Output | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `Style Prompt Library` | `Style Prompt Library` | `style_guide` (`STRING`) | Pick one style from the library and output its description. |
|
||||
| `Combine Styles` | `Style Prompt Library` | `style_guide` (`STRING`) | Combine up to three style strings into one LLM-friendly guide. |
|
||||
|
||||
## Features
|
||||
|
||||
- 306 local style entries across two JSON packs.
|
||||
- Cascading `Category -> Subcategory -> Style` controls.
|
||||
- Live search across the full style library.
|
||||
- Read-only preview of the selected style text.
|
||||
- Headless/API-safe behavior: the selected `style` value remains the source of truth.
|
||||
- Style strength control with two modes:
|
||||
- `instruction` for LLM prompt nodes.
|
||||
- `emphasis` for CLIP-style `(text:weight)` syntax.
|
||||
- Master `enabled` switch for quick A/B testing.
|
||||
- Multi-pack loading: every `*.json` file in `styles/` is loaded and merged.
|
||||
- No external Python dependencies.
|
||||
|
||||
## Included Files
|
||||
|
||||
```text
|
||||
ComfyUI-StylePromptLibrary/
|
||||
|-- __init__.py
|
||||
|-- style_library_node.py
|
||||
|-- combine_styles_node.py
|
||||
|-- style_creator.md
|
||||
|-- styles/
|
||||
| |-- styles_library.json
|
||||
| `-- nsfw.json
|
||||
|-- web/
|
||||
| `-- style_library.js
|
||||
`-- README.md
|
||||
```
|
||||
|
||||
## Style Packs
|
||||
|
||||
The node loads every JSON file in `styles/`.
|
||||
|
||||
| Pack | Entries | Notes |
|
||||
| --- | ---: | --- |
|
||||
| `styles_library.json` | 296 | Main general-purpose style library. |
|
||||
| `nsfw.json` | 10 | Optional adult style pack. Remove this file if you do not want adult styles loaded. |
|
||||
|
||||
Total local entries: 306.
|
||||
|
||||
## Style Schema
|
||||
|
||||
Each style entry is keyed by a unique ID and uses a flat object:
|
||||
|
||||
```json
|
||||
{
|
||||
"FilmNoir": {
|
||||
"name": "Film Noir",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "film noir cinematography, high-contrast black and white, hard chiaroscuro lighting, deep crushed blacks, low-key mood, smoky 1940s atmosphere, visible film grain"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `name` | yes | Human-readable label shown in the dropdown. Keep names unique. |
|
||||
| `category` | yes | Top-level style group. |
|
||||
| `subcategory` | yes | Second-level style group. |
|
||||
| `content` | yes | The style guide text emitted by the node. |
|
||||
|
||||
The loader also supports an alternate packed format with a top-level `categories` object, but the included library uses the flat schema above.
|
||||
|
||||
## Main Categories
|
||||
|
||||
The general library is organized mostly by medium and production style:
|
||||
|
||||
- Photography
|
||||
- Painting
|
||||
- Illustration
|
||||
- Anime & Manga
|
||||
- 3D & Render
|
||||
- Drawing & Sketch
|
||||
- Vector & Flat
|
||||
- Graphic Design
|
||||
- Crafts & Materials
|
||||
- Art Movements
|
||||
- Sci-Fi & Futurism
|
||||
- Fashion
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy this folder into your ComfyUI custom nodes directory:
|
||||
|
||||
```text
|
||||
ComfyUI/custom_nodes/ComfyUI-StylePromptLibrary
|
||||
```
|
||||
|
||||
2. Restart ComfyUI.
|
||||
3. Hard-refresh the browser page with `Ctrl+F5` so the frontend JavaScript reloads.
|
||||
4. Add the node from:
|
||||
|
||||
```text
|
||||
Add Node -> Style Prompt Library -> Style Prompt Library
|
||||
```
|
||||
|
||||
There are no extra Python packages to install.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add `Style Prompt Library`.
|
||||
2. Pick a `Category`.
|
||||
3. Pick a `Subcategory`.
|
||||
4. Pick a `Style`.
|
||||
5. Connect `style_guide` to your LLM prompt node, prompt builder, or conditioning workflow.
|
||||
|
||||
You can also use `search` to filter styles by name across the entire library. When search text is present, it overrides the category/subcategory filter in the frontend.
|
||||
|
||||
## Style Strength
|
||||
|
||||
The `strength` input lets you control how strongly the selected style should influence the output.
|
||||
|
||||
| Setting | Behavior |
|
||||
| --- | --- |
|
||||
| `0.0` | Outputs an empty string. |
|
||||
| `1.0` | Outputs the style text unchanged. |
|
||||
| `> 1.0` | Pushes the style more strongly. |
|
||||
|
||||
### `instruction` mode
|
||||
|
||||
Best when feeding an LLM prompt node. The node wraps the style text in a natural-language directive, such as:
|
||||
|
||||
```text
|
||||
Apply the following style strongly, as the dominant visual treatment: ...
|
||||
```
|
||||
|
||||
### `emphasis` mode
|
||||
|
||||
Best only when feeding CLIP-style prompt text directly. The node outputs:
|
||||
|
||||
```text
|
||||
(style text:1.25)
|
||||
```
|
||||
|
||||
## Combining Styles
|
||||
|
||||
Use `Combine Styles` to blend up to three style strings.
|
||||
|
||||
Inputs:
|
||||
|
||||
| Input | Description |
|
||||
| --- | --- |
|
||||
| `style_1` | Primary style. Required. |
|
||||
| `style_2` | Secondary accent style. Optional. |
|
||||
| `style_3` | Third accent style. Optional. |
|
||||
| `label_priority` | Adds `PRIMARY STYLE` and `ACCENT STYLES` labels so an LLM knows which style wins conflicts. |
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
```text
|
||||
Style Prompt Library -> style_1
|
||||
Style Prompt Library -> style_2
|
||||
Style Prompt Library -> style_3
|
||||
Combine Styles -> LLM prompt node
|
||||
```
|
||||
|
||||
Best results usually come from combining different dimensions, for example:
|
||||
|
||||
- one medium
|
||||
- one lighting or rendering treatment
|
||||
- one fashion, mood, or genre accent
|
||||
|
||||
Combining multiple strong mediums can produce conflicting instructions.
|
||||
|
||||
## Maintaining The Library
|
||||
|
||||
### Add a style manually
|
||||
|
||||
Open a JSON file in `styles/` and add a new entry:
|
||||
|
||||
```json
|
||||
"MyNewStyle": {
|
||||
"name": "My New Style",
|
||||
"category": "Photography",
|
||||
"subcategory": "Portrait",
|
||||
"content": "soft natural light portrait, 85mm lens, shallow depth of field, gentle skin tones"
|
||||
}
|
||||
```
|
||||
|
||||
Restart ComfyUI after editing the style packs.
|
||||
|
||||
### Add a new style pack
|
||||
|
||||
Create another `.json` file in `styles/`:
|
||||
|
||||
```text
|
||||
styles/my_custom_pack.json
|
||||
```
|
||||
|
||||
The node will load it automatically on restart.
|
||||
|
||||
### Use the style creator prompt
|
||||
|
||||
`style_creator.md` is a portable prompt you can give to an LLM to generate new style entries in the expected JSON schema.
|
||||
|
||||
## How It Works
|
||||
|
||||
`style_library_node.py` loads all JSON files from `styles/`, builds an internal lookup table, and exposes two local routes to the ComfyUI frontend:
|
||||
|
||||
```text
|
||||
/style_prompt_library/tree
|
||||
/style_prompt_library/content
|
||||
```
|
||||
|
||||
`web/style_library.js` uses those routes to populate cascading dropdowns, live search, and the preview box. The Python node still works without the JavaScript because the saved `style` value is what determines the output.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
| --- | --- |
|
||||
| Node appears but dropdowns look flat | Hard-refresh the browser with `Ctrl+F5` so `web/style_library.js` reloads. |
|
||||
| New styles do not appear | Restart ComfyUI after editing JSON files. |
|
||||
| A style outputs an error | Check that the selected entry has a non-empty `content` field. |
|
||||
| Duplicate labels look strange | Keep `name` values unique. The loader adds a key suffix when names collide. |
|
||||
| Adult styles appear unexpectedly | Remove `styles/nsfw.json` and restart ComfyUI. |
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See [LICENSE](LICENSE).
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"""Style Prompt Library — categorized style-guide picker for ComfyUI.
|
||||
|
||||
Outputs the full prompt-engineering guide text for a chosen style, ready to
|
||||
feed into any LLM / prompt node.
|
||||
"""
|
||||
|
||||
from .style_library_node import (
|
||||
NODE_CLASS_MAPPINGS as _LIB_NODES,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as _LIB_NAMES,
|
||||
)
|
||||
from .combine_styles_node import (
|
||||
NODE_CLASS_MAPPINGS as _COMBINE_NODES,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as _COMBINE_NAMES,
|
||||
)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {**_LIB_NODES, **_COMBINE_NODES}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {**_LIB_NAMES, **_COMBINE_NAMES}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Combine Styles — blend up to 3 style blocks into one STRING for the LLM node.
|
||||
|
||||
The first style is the PRIMARY (base look); styles 2-3 are ACCENTS woven in on top,
|
||||
deferring to PRIMARY on any conflict. Best results come from combining ACROSS
|
||||
dimensions (e.g. a subject + a wardrobe + a setting), not within (two mediums clash).
|
||||
|
||||
Pure text join — no model call. Wire 1-3 "Style Prompt Library" nodes into this,
|
||||
then this node's output into the LLM node's `style` input.
|
||||
"""
|
||||
|
||||
|
||||
class CombineStyles:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"style_1": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
"style_2": ("STRING", {"forceInput": True}),
|
||||
"style_3": ("STRING", {"forceInput": True}),
|
||||
"label_priority": ("BOOLEAN", {"default": True,
|
||||
"tooltip": "Add PRIMARY/ACCENT headers so the LLM knows which style wins a conflict."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("style_guide",)
|
||||
FUNCTION = "combine"
|
||||
CATEGORY = "Style Prompt Library"
|
||||
DESCRIPTION = "Blend up to 3 style blocks; the first is PRIMARY, the rest are accents."
|
||||
|
||||
def combine(self, style_1, style_2="", style_3="", label_priority=True):
|
||||
styles = [s.strip() for s in (style_1, style_2, style_3) if s and s.strip()]
|
||||
if not styles:
|
||||
return ("",)
|
||||
if len(styles) == 1:
|
||||
return (styles[0],)
|
||||
|
||||
if not label_priority:
|
||||
return ("\n\n".join(styles),)
|
||||
|
||||
parts = ["PRIMARY STYLE:", styles[0], "",
|
||||
"ACCENT STYLES (blend in as secondary treatment; defer to PRIMARY on any conflict):"]
|
||||
for accent in styles[1:]:
|
||||
parts.append(accent)
|
||||
parts.append("")
|
||||
return ("\n".join(parts).strip(),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"CombineStyles": CombineStyles}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"CombineStyles": "Combine Styles"}
|
||||
@@ -0,0 +1,69 @@
|
||||
You are a Style Author for an image-prompt style library. The user gives you one or more
|
||||
style ideas. For each idea, output one JSON entry describing that style.
|
||||
|
||||
Return ONLY a JSON object, one entry per idea, in exactly this shape:
|
||||
|
||||
{
|
||||
"KeyName": {
|
||||
"name": "Readable Name",
|
||||
"category": "Category",
|
||||
"subcategory": "Subcategory",
|
||||
"content": "comma, separated, descriptive, style, phrases"
|
||||
}
|
||||
}
|
||||
|
||||
FIELDS
|
||||
- KeyName: CamelCase, letters/digits only, derived from the name, unique.
|
||||
- name: the human-readable label (Title Case). No prefixes.
|
||||
- category: EXACTLY one of the categories below.
|
||||
- subcategory: EXACTLY one of that category's subcategories below. Never invent new ones.
|
||||
- content: the actual style text the image model reads — a SINGLE LINE of comma-separated
|
||||
descriptive phrases (not sentences, no labels, no line breaks). ~25-60 phrases.
|
||||
|
||||
CONTENT RULES
|
||||
- Describe the STYLE only — the medium, technique, rendering, lighting feel, mood, color,
|
||||
and signature visual traits that make this style recognizable.
|
||||
- Write renderable visual phrases, e.g. "oil painting, visible brushstrokes, impasto
|
||||
texture, warm earthy palette, dramatic chiaroscuro lighting, classical composition".
|
||||
- A style MAY fix a subject if that is part of the style (e.g. a "Cute Robot 3D" style
|
||||
names a cute robot). If the style is a pure look, describe no subject — the user supplies it.
|
||||
- NO instructions ("you are", "render", "output"), NO {prompt} placeholder, NO model or
|
||||
artist names, NO markdown, NO negative / "avoid" lists, NO quality boilerplate
|
||||
(8k, masterpiece, highly detailed, best quality).
|
||||
- Commit to concrete values; no hedging, no "etc.", no "or".
|
||||
|
||||
CATEGORIES (category -> allowed subcategories)
|
||||
- Photography: Cinematic, Portrait, Fashion & Editorial, Documentary & Street, Landscape & Aerial, Macro & Close-up, Product & Still Life, Fine Art & B&W, Night & Long Exposure, Nature & Wildlife, Experimental & Surreal
|
||||
- Painting: Oil & Impasto, Acrylic & Gouache, Watercolor, Ink & Eastern, Classical & Fresco, Modern & Abstract, Fantasy & Concept, Digital, Street & Graffiti
|
||||
- Illustration: Cartoon, Children's Book, Comic & Pop Art, Fantasy & Concept, Sticker, Retro & Modern, Art Deco & Nouveau, Pixel & Game, Classic & Editorial
|
||||
- Anime & Manga: Anime, Chibi & Kawaii, Mecha, Realistic Anime
|
||||
- 3D & Render: Character, Stylized & Cute, Abstract, Hard Surface & Mech, Product & Architectural, Low Poly & Isometric, Game Asset, Sci-Fi
|
||||
- Drawing & Sketch: Pencil & Graphite, Charcoal & Pastel, Ink & Pen, Caricature, Technical & Zentangle
|
||||
- Vector & Flat: Flat Art, Line Art, Silhouette, Logo & Symbol, Coloring Book
|
||||
- Graphic Design: Poster & Banner, Logo & Icon, Packaging & Branding, Layout & Print, Web & Digital, Infographic
|
||||
- Crafts & Materials: Ceramic & Porcelain, Sculpture, Paper, Textile, Glass & Mosaic, Wood & Metal, Leather & Other
|
||||
- Art Movements: Classical & Baroque, Modern (20th C.), Contemporary, Cultural & Folk, Surreal & Dreamscape
|
||||
- Sci-Fi & Futurism: Cyberpunk, Retro-Futurism, Bio & Cyber, Eco & Solar, Synthwave & Vaporwave, Space & Cosmic
|
||||
- Fashion: Casual & Streetwear, Glamour & Swimwear, Subculture & Alternative, Costume & Cosplay, Sci-Fi & Fantasy
|
||||
|
||||
Pick the category by the style's MEDIUM (how it's made). Genre/aesthetic styles that are
|
||||
not a medium go to Art Movements or Sci-Fi & Futurism. Wardrobe styles go to Fashion.
|
||||
|
||||
EXAMPLES
|
||||
|
||||
{
|
||||
"FilmNoir": {
|
||||
"name": "Film Noir",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "film noir cinematography, high-contrast black and white, hard chiaroscuro lighting, deep crushed blacks, venetian-blind shadow patterns, low-key mood, smoky 1940s atmosphere, dramatic silhouettes, visible film grain, 35mm, mysterious and fatalistic tone"
|
||||
},
|
||||
"Chiaroscuro": {
|
||||
"name": "Chiaroscuro / Old Master",
|
||||
"category": "Painting",
|
||||
"subcategory": "Classical & Fresco",
|
||||
"content": "old master oil painting, chiaroscuro, tenebrism, single hard key light from upper side, steep falloff, rich black shadows, warm highlights, painterly medium-format look, fine canvas texture, oil-paint sheen, solemn baroque mood, muted earthy palette"
|
||||
}
|
||||
}
|
||||
|
||||
Return only the JSON object.
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Style Prompt Library — Improved version for the new structured JSON
|
||||
with explicit subcategories.
|
||||
|
||||
Key improvements:
|
||||
- Properly uses the "subcategory" field from the new JSON
|
||||
- Better cascading Category → Subcategory → Style experience
|
||||
- Loads from styles/ folder (supports multiple packs)
|
||||
- Single style_guide output (for LLM prompt nodes)
|
||||
- Minimal changes from original node logic
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
import hashlib
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_STYLES_DIR = os.path.join(_HERE, "styles")
|
||||
|
||||
|
||||
def _pack_files():
|
||||
"""All style-pack JSON files from the styles/ folder."""
|
||||
files = sorted(glob.glob(os.path.join(_STYLES_DIR, "*.json")))
|
||||
if not files:
|
||||
legacy = os.path.join(_HERE, "styles_library.json")
|
||||
if os.path.exists(legacy):
|
||||
files = [legacy]
|
||||
return files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load library
|
||||
# ---------------------------------------------------------------------------
|
||||
_ENTRIES = {}
|
||||
_LABEL_TO_KEY = {}
|
||||
_TREE = {}
|
||||
_CATEGORIES = []
|
||||
_ALL_LABELS = []
|
||||
_LOAD_ERROR = None
|
||||
|
||||
|
||||
def _load_library():
|
||||
global _LOAD_ERROR
|
||||
files = _pack_files()
|
||||
|
||||
if not files:
|
||||
_LOAD_ERROR = f"No JSON files found in {_STYLES_DIR}"
|
||||
print(f"[StylePromptLibrary] {_LOAD_ERROR}")
|
||||
return
|
||||
|
||||
raw = {}
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8") as f:
|
||||
pack = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[StylePromptLibrary] Skipped {os.path.basename(fp)}: {e}")
|
||||
continue
|
||||
|
||||
# Support new improved format with subcategories
|
||||
if "categories" in pack:
|
||||
for cat_name, cat_data in pack.get("categories", {}).items():
|
||||
for style in cat_data.get("styles", []):
|
||||
full_name = style.get("full_name") or style.get("name", "")
|
||||
if not full_name:
|
||||
continue
|
||||
|
||||
key = full_name
|
||||
subcategory = style.get("subcategory", "General")
|
||||
|
||||
raw[key] = {
|
||||
"name": full_name,
|
||||
"category": cat_name,
|
||||
"subcategory": subcategory,
|
||||
"content": style.get("prompt", "")
|
||||
}
|
||||
continue
|
||||
|
||||
# Fallback to old flat format
|
||||
for k, v in pack.items():
|
||||
raw[k] = v
|
||||
|
||||
if not raw:
|
||||
_LOAD_ERROR = "No valid entries found in style packs."
|
||||
return
|
||||
|
||||
print(f"[StylePromptLibrary] Loaded {len(files)} pack(s)")
|
||||
|
||||
# Build display labels and tree
|
||||
name_counts = {}
|
||||
for entry in raw.values():
|
||||
name_counts[entry.get("name", "")] = name_counts.get(entry.get("name", ""), 0) + 1
|
||||
|
||||
tree = {}
|
||||
for key, entry in raw.items():
|
||||
_ENTRIES[key] = entry
|
||||
label = entry.get("name", key)
|
||||
if name_counts.get(label, 0) > 1:
|
||||
label = f"{label} · {key}"
|
||||
_LABEL_TO_KEY[label] = key
|
||||
|
||||
cat = entry.get("category", "Misc")
|
||||
sub = entry.get("subcategory", "General")
|
||||
tree.setdefault(cat, {}).setdefault(sub, []).append(label)
|
||||
|
||||
# Sort
|
||||
for cat in tree:
|
||||
for sub in tree[cat]:
|
||||
tree[cat][sub].sort(key=str.lower)
|
||||
|
||||
_TREE.update({c: dict(sorted(tree[c].items())) for c in sorted(tree)})
|
||||
_CATEGORIES.extend(sorted(_TREE.keys()))
|
||||
_ALL_LABELS.extend(sorted(_LABEL_TO_KEY.keys(), key=str.lower))
|
||||
|
||||
print(f"[StylePromptLibrary] Loaded {len(_ENTRIES)} styles in {len(_CATEGORIES)} categories.")
|
||||
|
||||
|
||||
_load_library()
|
||||
|
||||
# UI options
|
||||
_CATEGORY_OPTIONS = ["All"] + _CATEGORIES if _CATEGORIES else ["(no styles loaded)"]
|
||||
_SUBCATEGORY_OPTIONS = ["All"] + sorted({s for subs in _TREE.values() for s in subs}) if _TREE else ["All"]
|
||||
_STYLE_OPTIONS = _ALL_LABELS if _ALL_LABELS else ["(no styles loaded)"]
|
||||
|
||||
|
||||
# Server routes
|
||||
try:
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
@PromptServer.instance.routes.get("/style_prompt_library/tree")
|
||||
async def _style_tree(request):
|
||||
return web.json_response({
|
||||
"categories": _CATEGORIES,
|
||||
"tree": _TREE,
|
||||
"all": _ALL_LABELS,
|
||||
"error": _LOAD_ERROR,
|
||||
})
|
||||
|
||||
@PromptServer.instance.routes.get("/style_prompt_library/content")
|
||||
async def _style_content(request):
|
||||
label = request.query.get("label", "")
|
||||
key = _LABEL_TO_KEY.get(label) or label
|
||||
content = _ENTRIES.get(key, {}).get("content", "") if key in _ENTRIES else ""
|
||||
return web.json_response({"content": content})
|
||||
except Exception as e:
|
||||
print(f"[StylePromptLibrary] Could not register routes: {e}")
|
||||
|
||||
|
||||
class StylePromptLibrary:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"category": (_CATEGORY_OPTIONS,),
|
||||
"subcategory": (_SUBCATEGORY_OPTIONS,),
|
||||
"style": (_STYLE_OPTIONS,),
|
||||
},
|
||||
"optional": {
|
||||
"search": ("STRING", {"default": "", "multiline": False,
|
||||
"placeholder": "type to filter styles…"}),
|
||||
"enabled": ("BOOLEAN", {"default": True,
|
||||
"label_on": "styles ON", "label_off": "styles OFF",
|
||||
"tooltip": "Master switch. When OFF, this node outputs an empty "
|
||||
"string so no style is applied — lets you A/B with one click."}),
|
||||
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05,
|
||||
"tooltip": "Style weight, like a LoRA. 1.0 = normal (text unchanged), "
|
||||
"0 = no style at all, >1 pushes it harder. See strength_mode "
|
||||
"for how the weight is applied."}),
|
||||
"strength_mode": (["instruction", "emphasis"], {"default": "instruction",
|
||||
"tooltip": "instruction: expresses the weight as a directive for an LLM "
|
||||
"prompt node (use when this feeds your LLM node). emphasis: wraps "
|
||||
"as (text:weight) attention syntax for a CLIP Text Encode (use "
|
||||
"only when feeding CLIP directly)."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("style_guide",)
|
||||
FUNCTION = "get_style"
|
||||
CATEGORY = "Style Prompt Library"
|
||||
|
||||
def get_style(self, category, subcategory, style, search="",
|
||||
enabled=True, strength=1.0, strength_mode="instruction"):
|
||||
# Master switch — OFF means this node contributes nothing.
|
||||
if not enabled:
|
||||
return ("",)
|
||||
|
||||
if _LOAD_ERROR:
|
||||
return (f"[StylePromptLibrary ERROR] {_LOAD_ERROR}",)
|
||||
|
||||
key = _LABEL_TO_KEY.get(style)
|
||||
if key is None:
|
||||
if style in _ENTRIES:
|
||||
key = style
|
||||
else:
|
||||
return (f"[StylePromptLibrary ERROR] Unknown style: {style!r}",)
|
||||
|
||||
content = _ENTRIES.get(key, {}).get("content", "")
|
||||
if not content:
|
||||
return (f"[StylePromptLibrary ERROR] Style {key!r} has no content.",)
|
||||
|
||||
return (self._apply_strength(content, float(strength), strength_mode),)
|
||||
|
||||
@staticmethod
|
||||
def _apply_strength(content, strength, mode):
|
||||
"""Emulate a LoRA-like weight on a text style block.
|
||||
|
||||
There is no tensor here, so 'strength' is expressed one of two ways:
|
||||
- emphasis: CLIP attention syntax (text:weight) — for a CLIP Text Encode.
|
||||
- instruction: a worded directive — for an LLM prompt node (default).
|
||||
"""
|
||||
# 0 weight → no style contribution, like a LoRA at 0.
|
||||
if strength <= 0.0:
|
||||
return ""
|
||||
# 1.0 is the identity / normal weight — leave the text untouched.
|
||||
if abs(strength - 1.0) < 1e-3:
|
||||
return content
|
||||
|
||||
if mode == "emphasis":
|
||||
# Escape any literal parens so they don't break the weight syntax.
|
||||
safe = content.replace("(", r"\(").replace(")", r"\)")
|
||||
return f"({safe}:{strength:.2f})"
|
||||
|
||||
# default "instruction" mode — a directive the LLM honors when it
|
||||
# weaves style_description into the final prompt.
|
||||
if strength < 0.5:
|
||||
phrase = "only subtly, as a faint accent"
|
||||
elif strength < 0.85:
|
||||
phrase = "as a light, secondary influence"
|
||||
elif strength <= 1.15:
|
||||
phrase = "as a clear, present treatment"
|
||||
elif strength <= 1.6:
|
||||
phrase = "strongly, as the dominant visual treatment"
|
||||
else:
|
||||
phrase = "as the overriding aesthetic that defines the entire image"
|
||||
return f"Apply the following style {phrase}: {content}"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, category, subcategory, style, search="",
|
||||
enabled=True, strength=1.0, strength_mode="instruction"):
|
||||
mtimes = []
|
||||
for fp in _pack_files():
|
||||
try:
|
||||
mtimes.append(os.path.getmtime(fp))
|
||||
except OSError:
|
||||
pass
|
||||
sig = f"{style}|{enabled}|{strength}|{strength_mode}|{max(mtimes) if mtimes else 0}"
|
||||
return hashlib.sha1(sig.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"StylePromptLibrary": StylePromptLibrary}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"StylePromptLibrary": "🎨 Style Prompt Library"}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"NSFWEroticSoloMasturbation": {
|
||||
"name": "Erotic Solo Masturbation",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "erotic solo masturbation photography, slender nude woman, fingers between legs, soft cinematic lighting, warm skin flush, glistening wetness, detailed skin texture, shallow depth of field, sensual intimate mood, subtle sweat on body, elegant slim curves, soft shadows accentuating form, creamy bokeh background, natural bedroom setting, realistic rendering, gentle side key light, rim lighting on edges, passionate expression, perky breasts, toned stomach"
|
||||
},
|
||||
"NSFWSeductiveSoloNude": {
|
||||
"name": "Seductive Solo Nude",
|
||||
"category": "Photography",
|
||||
"subcategory": "Portrait",
|
||||
"content": "seductive solo nude portrait, slender woman standing pose, teasing direct gaze, soft natural window light, gentle shadows on slim waist, perky breasts, long toned legs, warm neutral skin tones, shallow depth of field, elegant boudoir photography, confident sensual mood, subtle body sheen, graceful hand position, feminine curves, soft rim lighting, intimate personal space, natural makeup, flowing hair, relaxed alluring stance"
|
||||
},
|
||||
"NSFWMissionaryPassion": {
|
||||
"name": "Passionate Missionary",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "passionate missionary position, slender woman on back, legs wrapped around man, deep penetration, eye contact, soft warm bedroom lighting, glistening sweat on bodies, rumpled white sheets, shallow depth of field, intimate cinematic atmosphere, flushed skin tones, dynamic thrusting pose, elegant body lines, soft shadows, realistic skin texture, sensual emotional mood, warm highlights on curves, natural indoor setting"
|
||||
},
|
||||
"NSFWDoggystyleRaw": {
|
||||
"name": "Raw Doggystyle",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "raw doggystyle photography, slender woman on all fours, man gripping hips, arched back, intense expression, dramatic side lighting, cool moody palette, glistening sweat, shallow depth of field, raw passionate energy, realistic skin texture, dynamic action pose, strong shadows, athletic bodies, natural window light, primal erotic mood, visible tension in muscles"
|
||||
},
|
||||
"NSFWCowgirlRide": {
|
||||
"name": "Cowgirl Ride",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "cowgirl position photography, slender woman riding on top, hands on chest, breasts bouncing, head thrown back, bright natural daylight, soft airy lighting, detailed penetration, glistening wetness, toned thighs flexing, shallow depth of field, sensual movement, elegant feminine form, rumpled sheets, warm skin highlights, dynamic composition, passionate expression"
|
||||
},
|
||||
"NSFWCunnilingusCloseup": {
|
||||
"name": "Intimate Cunnilingus",
|
||||
"category": "Photography",
|
||||
"subcategory": "Macro & Close-up",
|
||||
"content": "intimate cunnilingus macro, slender woman receiving oral, man between spread legs, tongue on clitoris, detailed wet textures, soft pink tones, flushed inner thighs, soft diffused lighting, extreme shallow depth of field, creamy bokeh, sensual tender mood, realistic skin micro details, moisture visible, elegant intimate composition, warm skin palette, tasteful explicit fine art"
|
||||
},
|
||||
"NSFWEroticFellatio": {
|
||||
"name": "Erotic Fellatio",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "erotic fellatio photography, slender woman performing oral, lips wrapped around shaft, eye contact, hand stroking base, soft low key lighting, dramatic shadows on face, glistening saliva, flushed cheeks, shallow depth of field, realistic anatomy, intimate close-up, cinematic moody atmosphere, detailed wet surfaces, sensual expression, elegant hands, private luxurious setting"
|
||||
},
|
||||
"NSFWStandingWallSex": {
|
||||
"name": "Standing Wall Sex",
|
||||
"category": "Photography",
|
||||
"subcategory": "Cinematic",
|
||||
"content": "standing wall sex photography, slender woman lifted against wall, legs wrapped around man, passionate kissing, dramatic cinematic lighting, strong contrast, sweat on bodies, flexed muscles, shallow depth of field, dynamic action pose, cool blue and warm skin contrast, intense expressions, realistic body physics, modern interior, sensual powerful mood, high contrast lighting"
|
||||
},
|
||||
"NSFWSensualSpooning": {
|
||||
"name": "Sensual Spooning",
|
||||
"category": "Photography",
|
||||
"subcategory": "Fine Art & B&W",
|
||||
"content": "sensual spooning fine art, slender woman and man on side, gentle penetration from behind, tender neck kissing, soft black and white photography, delicate tonal gradations, intimate loving mood, soft window light, elegant body lines, shallow depth of field, timeless romantic atmosphere, subtle grain, classic boudoir aesthetic, peaceful slow lovemaking, natural curves, delicate embrace"
|
||||
},
|
||||
"NSFWEroticCoupleOilPainting": {
|
||||
"name": "Erotic Couple Oil Painting",
|
||||
"category": "Painting",
|
||||
"subcategory": "Oil & Impasto",
|
||||
"content": "erotic couple oil painting, slender nude woman and man in passionate embrace, thick impasto brushstrokes, rich warm earthy palette, dramatic chiaroscuro lighting, visible canvas texture, glistening wet skin, sensual intertwined bodies, heavy paint texture, emotional erotic mood, bold color contrasts, dynamic composition, tactile oil surface, warm golden skin tones, fine art painting aesthetic, elegant yet raw energy"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
// Style Prompt Library — cascading dropdowns + live search + preview + theming.
|
||||
//
|
||||
// Wires Category -> Subcategory -> Style so each narrows the next, searches across
|
||||
// the library, shows the selected style's text in a read-only preview box, and gives
|
||||
// the node a distinct colour. The `style` widget stays the source of truth.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { ComfyWidgets } from "/scripts/widgets.js";
|
||||
|
||||
const NODE = "StylePromptLibrary";
|
||||
let DATA = null; // { categories, tree, all }
|
||||
const contentCache = {}; // label -> content text
|
||||
|
||||
async function loadData() {
|
||||
if (DATA) return DATA;
|
||||
try {
|
||||
const res = await api.fetchApi("/style_prompt_library/tree");
|
||||
DATA = await res.json();
|
||||
} catch (e) {
|
||||
console.warn("[StylePromptLibrary] tree fetch failed; using flat list.", e);
|
||||
DATA = null;
|
||||
}
|
||||
return DATA;
|
||||
}
|
||||
|
||||
async function fetchContent(label) {
|
||||
if (!label) return "";
|
||||
if (label in contentCache) return contentCache[label];
|
||||
try {
|
||||
const res = await api.fetchApi("/style_prompt_library/content?label=" + encodeURIComponent(label));
|
||||
const j = await res.json();
|
||||
contentCache[label] = j.content || "";
|
||||
} catch (e) {
|
||||
contentCache[label] = "";
|
||||
}
|
||||
return contentCache[label];
|
||||
}
|
||||
|
||||
const widgetOf = (node, name) => node.widgets?.find((w) => w.name === name);
|
||||
|
||||
function setOptions(widget, values, preferred) {
|
||||
if (!widget) return;
|
||||
widget.options.values = values;
|
||||
if (preferred && values.includes(preferred)) widget.value = preferred;
|
||||
else if (!values.includes(widget.value)) widget.value = values[0];
|
||||
}
|
||||
|
||||
function labelsFor(category, subcategory) {
|
||||
if (!DATA) return null;
|
||||
if (category === "All") return DATA.all.slice();
|
||||
const subs = DATA.tree[category] || {};
|
||||
if (subcategory && subcategory !== "All") return (subs[subcategory] || []).slice();
|
||||
return Object.values(subs).flat().sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
}
|
||||
|
||||
function subcatsFor(category) {
|
||||
if (!DATA || category === "All") return ["All"];
|
||||
return ["All", ...Object.keys(DATA.tree[category] || {})];
|
||||
}
|
||||
|
||||
function refreshStyleList(node) {
|
||||
const cat = widgetOf(node, "category");
|
||||
const sub = widgetOf(node, "subcategory");
|
||||
const style = widgetOf(node, "style");
|
||||
const search = widgetOf(node, "search");
|
||||
if (!style || !DATA) return;
|
||||
|
||||
let labels = labelsFor(cat?.value, sub?.value) || DATA.all.slice();
|
||||
const q = (search?.value || "").trim().toLowerCase();
|
||||
if (q) {
|
||||
labels = DATA.all.filter((l) => l.toLowerCase().includes(q));
|
||||
if (labels.length === 0) labels = ["(no match)"];
|
||||
}
|
||||
setOptions(style, labels, style.value);
|
||||
node._splUpdatePreview?.();
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// Read-only preview box showing the selected style's text.
|
||||
function addPreview(node) {
|
||||
if (node._splPreview) return;
|
||||
const w = ComfyWidgets["STRING"](node, "stylepreview", ["STRING", { multiline: true }], app).widget;
|
||||
w.serializeValue = async () => ""; // don't bloat the saved workflow
|
||||
const el = w.inputEl;
|
||||
if (el) {
|
||||
el.readOnly = true;
|
||||
el.placeholder = "Select a style to preview its description…";
|
||||
el.style.opacity = "0.92";
|
||||
el.style.fontSize = "11px";
|
||||
el.style.fontFamily = "ui-monospace, Consolas, monospace";
|
||||
el.style.background = "#1b1622";
|
||||
el.style.color = "#d8cfe6";
|
||||
el.style.border = "1px solid #3a2f4a";
|
||||
}
|
||||
node._splPreview = w;
|
||||
node._splUpdatePreview = async () => {
|
||||
const style = widgetOf(node, "style");
|
||||
w.value = await fetchContent(style?.value || "");
|
||||
};
|
||||
}
|
||||
|
||||
function styleNode(node) {
|
||||
node.color = "#4a2c6d"; // deep purple title bar
|
||||
node.bgcolor = "#241d2e"; // dark slate-purple body
|
||||
if (node.size[0] < 400) node.setSize([400, node.size[1]]);
|
||||
}
|
||||
|
||||
function wire(node) {
|
||||
if (node._splWired) return;
|
||||
node._splWired = true;
|
||||
|
||||
styleNode(node);
|
||||
addPreview(node);
|
||||
|
||||
const cat = widgetOf(node, "category");
|
||||
const sub = widgetOf(node, "subcategory");
|
||||
const search = widgetOf(node, "search");
|
||||
const style = widgetOf(node, "style");
|
||||
|
||||
const chain = (widget, fn) => {
|
||||
if (!widget) return;
|
||||
const orig = widget.callback;
|
||||
widget.callback = function () {
|
||||
const r = orig ? orig.apply(this, arguments) : undefined;
|
||||
try { fn(); } catch (e) { console.warn("[StylePromptLibrary]", e); }
|
||||
return r;
|
||||
};
|
||||
};
|
||||
|
||||
chain(cat, () => {
|
||||
setOptions(sub, subcatsFor(cat.value), "All");
|
||||
sub.value = "All";
|
||||
refreshStyleList(node);
|
||||
});
|
||||
chain(sub, () => refreshStyleList(node));
|
||||
chain(search, () => refreshStyleList(node));
|
||||
chain(style, () => node._splUpdatePreview?.());
|
||||
|
||||
if (cat) setOptions(sub, subcatsFor(cat.value), sub?.value);
|
||||
refreshStyleList(node);
|
||||
node._splUpdatePreview?.();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "StylePromptLibrary.CascadingMenu",
|
||||
async setup() { await loadData(); },
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE) return;
|
||||
const onCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onCreated?.apply(this, arguments);
|
||||
const node = this;
|
||||
(async () => { await loadData(); wire(node); })();
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user