feat: initial release v1.0.0 — CWK Prompt Composer

This commit is contained in:
cowneko
2026-03-05 23:56:48 +01:00
commit bdc00a172c
17 changed files with 1894 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
dist/
build/
.eggs/
*.egg
# Virtual environments
.venv/
venv/
env/
# OS
.DS_Store
Thumbs.db
desktop.ini
# Editors
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
# Node / npm (if ever used)
node_modules/
+28
View File
@@ -0,0 +1,28 @@
# Changelog
All notable changes to **CWK Prompt Composer** will be documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [1.0.0] - 2026-03-05
### Added
- Initial release of CWK Prompt Composer custom node for ComfyUI
- Four prompt panels: Quality, Main, Aesthetic, Negative
- Pill canvas with drag-to-reorder, selection, weight control (right-click)
- Tag browser: flat 2-level (Quality/Aesthetic/Negative) and 3-level nested (Main)
- Live filter search in tag browser
- Free-type custom tag input with Enter-to-add
- Join / Split selected pills
- Full undo / redo history (up to 100 steps)
- Preset save / load / rename / delete / export / import (JSON)
- 🎲 I Feel Lucky random prompt generator (Main panel)
- 🔞 NSFW toggle for Lucky generator (opt-in, stored in localStorage)
- 📌 Add-to-Tag-List: persist custom tags back to JSON files via `/cwk/add_tag` API
- Live positive/negative preview drawn directly on the ComfyUI node canvas
- Node serialisation: state saved and restored with workflow JSON
- Keyboard shortcuts: Ctrl+Enter confirm, Escape cancel, Ctrl+Z/Y undo/redo
- Curated tag libraries: quality.json, aesthetic.json, main.json, negative.json, lucky.json
- Resizable & draggable panel windows
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cowneko
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.
+109
View File
@@ -0,0 +1,109 @@
# CWK Prompt Composer
A custom node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that lets you compose positive and negative prompts using a visual **pill-based editor** with tag browsing, presets, drag-to-reorder, weight control, and a lucky prompt generator.
---
## ✨ Features
- **4 prompt panels** — Quality, Main, Aesthetic, and Negative prompts, each with their own curated tag library
- **Pill canvas** — Each tag is a draggable, reorderable pill; click to select, drag to rearrange
- **Weight control** — Right-click any pill to set its emphasis weight (e.g. `(tag:1.3)`)
- **Tag browser** — Browse tags organised by category and subcategory with a live filter
- Flat 2-level browser for Quality / Aesthetic / Negative panels
- 3-level nested browser for the Main panel
- **Free-type input** — Type any custom tag and press Enter to add it instantly
- **Join / Split** — Merge selected pills with `_` or split underscore-joined pills apart
- **Undo / Redo** — Full history (Ctrl+Z / Ctrl+Shift+Z or Ctrl+Y)
- **Presets** — Save, load, rename, export (JSON) and import presets per panel
- **🎲 I Feel Lucky** — Generate a random coherent prompt from subject / clothing / expression / action / environment pools
- **🔞 NSFW toggle** — Opt-in to include adult content in the Lucky generator
- **📌 Add to Tag List** — Pin any custom or free-typed tag back into the persistent JSON tag library
- **Live preview** — The node canvas shows live previews of the assembled positive and negative prompts
- **Keyboard shortcuts** — `Esc` to cancel, `Ctrl+Enter` to confirm, `Ctrl+Z/Y` for undo/redo
---
## 📦 Installation
### Via ComfyUI Manager (recommended)
Search for **CWK Prompt Composer** in the ComfyUI Manager and install directly.
### Manual installation
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/cowneko/CWK_Prompt_Composer.git
```
Then restart ComfyUI.
---
## 🗂️ File Structure
```
CWK_Prompt_Composer/
├── __init__.py ← Node registration + web directory
├── nodes.py ← Python node + /cwk/add_tag API route
└── web/
├── index.js ← Extension entry, node draw & state
├── pill_canvas.js ← PillCanvas widget + category colours
├── tag_browser.js ← Flat & 3-level nested tag browsers
├── panel_dialog.js ← Panel dialog, Lucky generator, NSFW toggle
├── preset_manager.js ← Preset manager, window builder, storage helpers
└── tags/
├── quality.json ← Quality prompt tags
├── aesthetic.json ← Aesthetic / style tags
├── main.json ← Main subject / clothing / action / environment tags
├── negative.json ← Negative prompt tags
└── lucky.json ← Lucky prompt pools (SFW + NSFW)
```
---
## 🖱️ Usage
1. Add the **CWK Prompt Composer** node to your workflow.
2. Click any of the four buttons on the node (⭐ Quality, 🖼️ Main, 🎨 Aesthetic, ❌ Negative) to open its panel.
3. Browse or filter tags in the bottom browser and click to add them as pills.
4. Type a custom tag in the input bar and press **Enter**.
5. Drag pills to reorder. Right-click a pill to set its weight or add it to the tag library.
6. Click **✅ Confirm** — the assembled prompt string is written back to the node widget.
7. The node outputs `positive_prompt` (Quality + Main + Aesthetic joined) and `negative_prompt`.
### Keyboard shortcuts (inside a panel)
| Shortcut | Action |
|---|---|
| `Ctrl + Enter` | Confirm |
| `Escape` | Cancel |
| `Ctrl + Z` | Undo |
| `Ctrl + Shift + Z` / `Ctrl + Y` | Redo |
---
## 🎨 Tag Categories
| Panel | Categories |
|---|---|
| **Quality** | Resolution, Detail, Rendering, Award Winning |
| **Aesthetic** | Framing, Art Style, Era, Mood, Color Palette, Lighting |
| **Main** | Characters, Clothing, Action, Environment, NSFW |
| **Negative** | Quality Issues, Anatomy, Faces, Hands, Composition |
---
## 🔧 Adding Tags Permanently
Right-click any pill in the canvas → **📌 Add to Tag List**, or click the **📌** button next to the free-type input. Choose the category and subcategory, then click **Add Tag**. The tag is written directly to the corresponding JSON file on disk.
---
## 📋 Presets
- **💾 Save Preset** — saves the current pill set under a name (stored in `localStorage`)
- **📋 Manage Presets** — load, rename, delete, export or import presets as JSON files
---
## 🪪 License
[MIT License](LICENSE) — © 2026 cowneko
+5
View File
@@ -0,0 +1,5 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+80
View File
@@ -0,0 +1,80 @@
import json
import os
import server
from aiohttp import web
# ── Tag directory ─────────────────────────────────────────────────────────────
TAG_DIR = os.path.join(os.path.dirname(__file__), "web", "tags")
# ── Route: add a tag to a JSON file ──────────────────────────────────────────
@server.PromptServer.instance.routes.post("/cwk/add_tag")
async def add_tag(request):
try:
data = await request.json()
panel_key = data.get("panelKey")
category = data.get("category")
subcategory = data.get("subcategory")
tag = data.get("tag", "").strip()
if not all([panel_key, category, subcategory, tag]):
return web.json_response({"ok": False, "error": "Missing fields"}, status=400)
filepath = os.path.join(TAG_DIR, f"{panel_key}.json")
if not os.path.exists(filepath):
return web.json_response({"ok": False, "error": f"File not found: {panel_key}.json"}, status=404)
with open(filepath, "r", encoding="utf-8") as f:
tag_data = json.load(f)
for group in tag_data:
if group.get("category") == category:
for sub in group.get("subcategories", []):
if sub.get("name") == subcategory:
if tag in sub["tags"]:
return web.json_response({
"ok": False,
"duplicate": True,
"where": f"{category} › {subcategory}"
})
sub["tags"].append(tag)
sub["tags"].sort()
with open(filepath, "w", encoding="utf-8") as f:
json.dump(tag_data, f, indent=2, ensure_ascii=False)
return web.json_response({"ok": True})
return web.json_response(
{"ok": False, "error": "Category or subcategory not found"}, status=404
)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
# ── Node ──────────────────────────────────────────────────────────────────────
class CWKPromptComposerNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"quality_prompt": ("STRING", {"default": "", "multiline": True}),
"main_prompt": ("STRING", {"default": "", "multiline": True}),
"aesthetic_prompt": ("STRING", {"default": "", "multiline": True}),
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("positive_prompt", "negative_prompt")
FUNCTION = "compose"
CATEGORY = "CWK"
def compose(self, quality_prompt, main_prompt, aesthetic_prompt, negative_prompt):
positive = ", ".join(p for p in [quality_prompt, main_prompt, aesthetic_prompt] if p.strip())
negative = negative_prompt.strip()
return (positive, negative)
# ── Mappings ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {"CWKPromptComposerNode": CWKPromptComposerNode}
NODE_DISPLAY_NAME_MAPPINGS = {"CWKPromptComposerNode": "CWK Prompt Composer"}
+174
View File
@@ -0,0 +1,174 @@
import { app } from "../../scripts/app.js";
import { TAG_DATA, LUCKY_DATA, panelDialog } from "./panel_dialog.js";
// ── Load tag + lucky data ────────────────────────────────────────────────────
async function loadTagData() {
const keys = ["quality", "aesthetic", "main", "negative"];
await Promise.all(keys.map(async (key) => {
try {
const res = await fetch(`/extensions/CWK_Prompt_Composer/tags/${key}.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
TAG_DATA[key] = await res.json();
} catch (e) {
console.error(`[CWK] Failed to load tags/${key}.json:`, e);
TAG_DATA[key] = [];
}
}));
try {
const res = await fetch(`/extensions/CWK_Prompt_Composer/tags/lucky.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// LUCKY_DATA is exported as `let` so we mutate its keys
const data = await res.json();
Object.assign(LUCKY_DATA, data);
} catch (e) {
console.error(`[CWK] Failed to load tags/lucky.json:`, e);
}
}
// ── Node State ───────────────────────────────────────────────────────────────
const nodeState = {};
function getState(nodeId) {
if (!nodeState[nodeId]) nodeState[nodeId] = { quality: "", main: "", aesthetic: "", negative: "" };
return nodeState[nodeId];
}
const BUTTONS = [
{ key: "quality", label: "⭐ Quality Prompt" },
{ key: "main", label: "🖼️ Main Prompt" },
{ key: "aesthetic", label: "🎨 Aesthetic Prompt" },
{ key: "negative", label: "❌ Negative Prompt" },
];
const BTN_H = 28, BTN_GAP = 6, BTN_PAD = 10;
function getTopOffset(node) {
const slotH = LiteGraph.NODE_SLOT_HEIGHT ?? 20;
const numSlots = Math.max(node.outputs?.length ?? 0, node.inputs?.length ?? 0);
return numSlots > 0 ? Math.ceil((numSlots - 1 + 0.7) * slotH) + 18 : 8;
}
function getButtonRects(node) {
const w = node.size[0], top = getTopOffset(node);
return BUTTONS.map((_, i) => ({ x: BTN_PAD, y: top + i * (BTN_H + BTN_GAP), w: w - BTN_PAD * 2, h: BTN_H }));
}
function syncWidgets(node) {
const s = getState(node.id);
const map = { quality_prompt: s.quality, main_prompt: s.main, aesthetic_prompt: s.aesthetic, negative_prompt: s.negative };
for (const w of (node.widgets ?? [])) { if (w.name in map) w.value = map[w.name]; }
}
function getPositive(s) { return [s.quality, s.main, s.aesthetic].filter(Boolean).join(", "); }
async function openPanel(node, key) {
const s = getState(node.id);
const btn = BUTTONS.find(b => b.key === key);
const result = await panelDialog.show({ title: btn.label, panelKey: key, defaultValue: s[key] ?? "" });
if (result !== null) { s[key] = result; syncWidgets(node); app.graph.setDirtyCanvas(true, true); }
}
// ── Draw ─────────────────────────────────────────────────────────────────────
function drawNode(node, ctx) {
const s = getState(node.id), rects = getButtonRects(node), w = node.size[0], top = getTopOffset(node);
ctx.save();
ctx.strokeStyle = "#313244"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(BTN_PAD, top - 8); ctx.lineTo(w - BTN_PAD, top - 8); ctx.stroke();
for (let i = 0; i < BUTTONS.length; i++) {
const { key, label } = BUTTONS[i], r = rects[i], hasValue = !!s[key];
const grad = ctx.createLinearGradient(r.x, r.y, r.x, r.y + r.h);
grad.addColorStop(0, hasValue ? "#2a3a2a" : "#313244");
grad.addColorStop(1, hasValue ? "#1a2a1a" : "#252535");
ctx.fillStyle = grad; ctx.strokeStyle = hasValue ? "#a6e3a1" : "#45475a"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 6); ctx.fill(); ctx.stroke();
ctx.fillStyle = hasValue ? "#a6e3a1" : "#cdd6f4";
ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2, r.w - 10);
}
const lastRect = rects[rects.length - 1];
const previewY = lastRect.y + lastRect.h + BTN_GAP * 2;
const previewH = node.size[1] - previewY - 8;
if (previewH < 30) { ctx.restore(); return; }
const halfH = Math.floor((previewH - BTN_GAP) / 2);
drawPreviewBox(ctx, { x: BTN_PAD, y: previewY, w: w - BTN_PAD * 2, h: halfH, label: "✅ POSITIVE", text: getPositive(s) || "(empty)", color: "#a6e3a1" });
drawPreviewBox(ctx, { x: BTN_PAD, y: previewY + halfH + BTN_GAP, w: w - BTN_PAD * 2, h: halfH, label: "❌ NEGATIVE", text: s.negative || "(empty)", color: "#f38ba8" });
ctx.restore();
}
function drawPreviewBox(ctx, { x, y, w, h, label, text, color }) {
ctx.fillStyle = "#11111b"; ctx.strokeStyle = "#313244"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(x, y, w, h, 5); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#6c7086"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillText(label, x + 6, y + 5);
ctx.save();
ctx.beginPath(); ctx.roundRect(x + 1, y + 1, w - 2, h - 2, 5); ctx.clip();
ctx.fillStyle = color; ctx.font = "11px sans-serif"; ctx.textBaseline = "top";
const lineH = 15, maxW = w - 12, startY = y + 18, maxLines = Math.max(1, Math.floor((h - 22) / lineH));
const words = text.split(/,\s*/);
let line = "", lineN = 0;
for (const word of words) {
const test = line ? line + ", " + word : word;
if (ctx.measureText(test).width > maxW && line) {
if (lineN < maxLines) ctx.fillText(line + (lineN === maxLines - 1 ? "…" : ","), x + 6, startY + lineN * lineH);
line = word; lineN++;
} else line = test;
}
if (line && lineN < maxLines) ctx.fillText(line, x + 6, startY + lineN * lineH);
ctx.restore();
}
// ── Extension ────────────────────────────────────────────────────────────────
app.registerExtension({
name: "CWK.PromptComposer",
async setup() {
await loadTagData();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "CWKPromptComposerNode") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply?.(this, arguments);
this.size = [340, 420];
getState(this.id);
const HIDDEN = ["quality_prompt", "main_prompt", "aesthetic_prompt", "negative_prompt"];
for (const w of (this.widgets ?? [])) {
if (HIDDEN.includes(w.name)) {
w.type = "converted-widget";
w.hidden = true;
w.computeSize = () => [0, -4];
}
}
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
onDrawForeground?.apply?.(this, arguments);
if (this.flags?.collapsed) return;
drawNode(this, ctx);
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, localPos) {
if (onMouseDown?.apply?.(this, arguments)) return true;
if (this.flags?.collapsed) return false;
const [mx, my] = localPos, rects = getButtonRects(this);
for (let i = 0; i < BUTTONS.length; i++) {
const r = rects[i];
if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) {
openPanel(this, BUTTONS[i].key);
return true;
}
}
return false;
};
nodeType.prototype.onSerialize = function (o) { o.cwk_state = getState(this.id); };
nodeType.prototype.onConfigure = function (o) {
if (o.cwk_state) { nodeState[this.id] = { ...o.cwk_state }; syncWidgets(this); }
};
},
});
+225
View File
@@ -0,0 +1,225 @@
import { PillCanvas } from "./pill_canvas.js";
import { buildTagBrowser } from "./tag_browser.js";
import { presetManager, loadPresets, savePresets, makeWindow } from "./preset_manager.js";
import { tagEditor } from "./tag_editor.js";
// ── Tag data store (filled by index.js) ─────────────────────────────────────
export const TAG_DATA = { quality: [], aesthetic: [], main: [], negative: [] };
export let LUCKY_DATA = {};
// ── NSFW toggle ──────────────────────────────────────────────────────────────
const NSFW_KEY = "cwk_nsfw_enabled";
function getNsfw() { return localStorage.getItem(NSFW_KEY) === "true"; }
function setNsfw(val) { localStorage.setItem(NSFW_KEY, val ? "true" : "false"); }
// ── Lucky prompt generator ───────────────────────────────────────────────────
function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function generateLuckyPrompt(nsfw) {
const d = LUCKY_DATA;
const pick = (sfwKey, nsfwKey) => {
const pool = (nsfw && nsfwKey && d[nsfwKey]?.length)
? [...(d[sfwKey] || []), ...(d[nsfwKey] || [])]
: (d[sfwKey] || []);
return pool.length ? pickRandom(pool) : null;
};
return [
pick("subject", "nsfw_subject"),
pick("clothing", "nsfw_clothing"),
pick("expression", null),
pick("action", "nsfw_action"),
pick("environment", null),
].filter(Boolean).map(p => ({ id: Math.random().toString(36).slice(2), text: p, category: "custom", weight: 1.0 }));
}
// ── Panel Dialog ─────────────────────────────────────────────────────────────
export class PanelDialog {
constructor() {
this._resolve = null;
this._canvas = null;
this._panelKey = null;
const { win, backdrop, body, closeBtn, titleLabel } = makeWindow({
title: "",
width: "700px",
height: "600px",
minWidth: "480px",
minHeight: "400px",
zIndex: "9999",
});
this._win = win;
this._backdrop = backdrop;
this._body = body;
this._titleLabel = titleLabel;
closeBtn.addEventListener("click", () => this._cancel());
}
show({ title, panelKey, defaultValue = "" }) {
this._panelKey = panelKey;
this._canvas = new PillCanvas(() => {});
this._canvas.setValue(defaultValue);
this._titleLabel.textContent = title;
this._body.innerHTML = "";
// wire up "Add to Tag List" callback
this._canvas.onAddToList = (tag) => {
tagEditor.show(tag, TAG_DATA[panelKey], panelKey);
};
const mkBtn = (text, bg, onClick) => {
const btn = document.createElement("button");
btn.textContent = text;
Object.assign(btn.style, { padding: "5px 12px", background: bg, color: "#cdd6f4", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "12px", whiteSpace: "nowrap" });
btn.addEventListener("click", onClick);
return btn;
};
// ── Free-type input ────────────────────────────────────────────────
const freeInput = document.createElement("input");
freeInput.type = "text";
freeInput.placeholder = "✏️ Type a custom tag and press Enter…";
Object.assign(freeInput.style, { flex: "1", padding: "5px 10px", background: "#1e1e2e", color: "#cdd6f4", border: "1px solid #45475a", borderRadius: "6px", fontSize: "12px", minWidth: "0" });
freeInput.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.stopPropagation();
const val = freeInput.value.trim();
if (val) { this._canvas.addTag(val, "custom"); freeInput.value = ""; }
});
// 📌 add free-typed tag to list
const pinBtn = mkBtn("📌", "#2a2a3b", () => {
const val = freeInput.value.trim();
if (!val) return;
tagEditor.show(val, TAG_DATA[panelKey], panelKey);
});
pinBtn.title = "Add this tag to the JSON list";
const undoBtn = mkBtn("↩ Undo", "#2a2a3b", () => this._canvas.undo());
const redoBtn = mkBtn("↪ Redo", "#2a2a3b", () => this._canvas.redo());
const freeRow = document.createElement("div");
Object.assign(freeRow.style, { display: "flex", gap: "6px", alignItems: "center", flexShrink: "0" });
freeRow.append(freeInput, pinBtn, undoBtn, redoBtn);
// ── Lucky row (main panel only) ────────────────────────────────────
const extraRows = [];
if (panelKey === "main") {
let nsfwOn = getNsfw();
const nsfwToggle = document.createElement("button");
const updateNsfwBtn = () => {
nsfwToggle.textContent = nsfwOn ? "🔞 NSFW: ON" : "🔒 NSFW: OFF";
Object.assign(nsfwToggle.style, {
padding: "5px 12px", borderRadius: "6px", cursor: "pointer",
fontSize: "12px", fontWeight: "bold", whiteSpace: "nowrap", border: "1px solid",
background: nsfwOn ? "#3b1a2a" : "#2a2a3b",
color: nsfwOn ? "#f38ba8" : "#6c7086",
borderColor: nsfwOn ? "#f38ba8" : "#45475a",
transition: "all 0.15s",
});
};
updateNsfwBtn();
nsfwToggle.addEventListener("click", () => { nsfwOn = !nsfwOn; setNsfw(nsfwOn); updateNsfwBtn(); });
const luckyBtn = document.createElement("button");
luckyBtn.textContent = "🎲 I Feel Lucky";
Object.assign(luckyBtn.style, { padding: "5px 14px", background: "#2a1f3b", color: "#cba6f7", border: "1px solid #cba6f7", borderRadius: "6px", cursor: "pointer", fontSize: "12px", fontWeight: "bold", whiteSpace: "nowrap" });
luckyBtn.addEventListener("click", () => this._canvas.setLucky(generateLuckyPrompt(getNsfw())));
const luckyRow = document.createElement("div");
Object.assign(luckyRow.style, { display: "flex", gap: "8px", alignItems: "center", flexShrink: "0" });
luckyRow.append(luckyBtn, nsfwToggle);
extraRows.push(luckyRow);
}
// ── Toolbar ────────────────────────────────────────────────────────
const toolbar = document.createElement("div");
Object.assign(toolbar.style, { display: "flex", gap: "6px", flexWrap: "wrap", alignItems: "center", flexShrink: "0" });
toolbar.append(
mkBtn("🗑 Clear", "#3b1f1f", () => this._canvas.clear()),
mkBtn("🔗 Join", "#1f2d3b", () => this._canvas.joinSelected()),
mkBtn("✂ Split", "#1f2d3b", () => this._canvas.splitSelected()),
mkBtn("💾 Save Preset", "#1f3b2a", () => this._savePreset()),
mkBtn("📋 Manage Presets", "#2a1f3b", () => presetManager.show(this._panelKey, this._canvas)),
);
// ── Browser ────────────────────────────────────────────────────────
const browser = buildTagBrowser(TAG_DATA[panelKey], panelKey, (tag, category) => this._canvas.addTag(tag, category));
const browserScroll = document.createElement("div");
Object.assign(browserScroll.style, { overflowY: "auto", flex: "1", minHeight: "0" });
browserScroll.appendChild(browser);
// ── Bottom bar ─────────────────────────────────────────────────────
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
Object.assign(cancelBtn.style, { padding: "8px 20px", background: "#313244", color: "#cdd6f4", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "14px" });
cancelBtn.addEventListener("click", () => this._cancel());
const confirmBtn = document.createElement("button");
confirmBtn.textContent = "✅ Confirm";
Object.assign(confirmBtn.style, { padding: "8px 24px", background: "#89b4fa", color: "#1e1e2e", border: "none", borderRadius: "6px", cursor: "pointer", fontWeight: "bold", fontSize: "14px" });
confirmBtn.addEventListener("click", () => this._confirm());
const bottomBar = document.createElement("div");
Object.assign(bottomBar.style, { display: "flex", justifyContent: "flex-end", gap: "8px", flexShrink: "0" });
bottomBar.append(cancelBtn, confirmBtn);
const hr = document.createElement("hr");
Object.assign(hr.style, { border: "none", borderTop: "1px solid #313244", margin: "0", flexShrink: "0" });
this._body.append(
this._canvas.el,
freeRow,
...extraRows,
toolbar,
hr,
browserScroll,
bottomBar,
);
// ── Keyboard shortcuts ─────────────────────────────────────────────
this._keyHandler = (e) => {
if (e.key === "Escape") { this._cancel(); return; }
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { this._confirm(); return; }
if (e.key === "z" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault(); this._canvas.undo(); return; }
if ((e.key === "y" && (e.ctrlKey || e.metaKey)) ||
(e.key === "z" && (e.ctrlKey || e.metaKey) && e.shiftKey)) { e.preventDefault(); this._canvas.redo(); return; }
};
document.addEventListener("keydown", this._keyHandler);
this._backdrop.style.display = "block";
this._win.style.display = "flex";
this._win.style.left = "50%";
this._win.style.top = "60px";
this._win.style.transform = "translateX(-50%)";
return new Promise((resolve) => { this._resolve = resolve; });
}
_confirm() {
document.removeEventListener("keydown", this._keyHandler);
const value = this._canvas?.getValue() ?? "";
this._backdrop.style.display = "none";
this._win.style.display = "none";
this._resolve?.(value);
this._resolve = null;
}
_cancel() {
document.removeEventListener("keydown", this._keyHandler);
this._backdrop.style.display = "none";
this._win.style.display = "none";
this._resolve?.(null);
this._resolve = null;
}
_savePreset() {
const name = window.prompt("Preset name:");
if (!name?.trim()) return;
const presets = loadPresets(this._panelKey);
presets[name.trim()] = this._canvas.getValue();
savePresets(this._panelKey, presets);
alert(`✅ Preset "${name.trim()}" saved!`);
}
}
export const panelDialog = new PanelDialog();
+375
View File
@@ -0,0 +1,375 @@
// ── Category → color map ─────────────────────────────────────────────────────
export const CATEGORY_COLORS = {
quality: "#f9e2af",
aesthetic: "#cba6f7",
main: "#89dceb",
negative: "#fab387",
custom: "#cdd6f4",
};
export function categoryColor(category) {
if (!category) return CATEGORY_COLORS.custom;
const key = category.toLowerCase();
for (const [k, v] of Object.entries(CATEGORY_COLORS)) {
if (key.includes(k)) return v;
}
return CATEGORY_COLORS.custom;
}
// ── Pill Canvas ──────────────────────────────────────────────────────────────
export class PillCanvas {
constructor(onChange) {
this.pills = [];
this.selected = new Set();
this.onChange = onChange;
this._dragIdx = null;
this._dragOver = null;
this._weightPopover = null;
this._ctxMenu = null;
this._undoStack = [];
this._redoStack = [];
// callbacks set by PanelDialog
this.onAddToList = null; // (tag) => void
this.el = document.createElement("div");
Object.assign(this.el.style, {
minHeight: "80px",
maxHeight: "160px",
overflowY: "auto",
background: "#11111b",
border: "1px solid #313244",
borderRadius: "8px",
padding: "8px",
display: "flex",
flexWrap: "wrap",
gap: "6px",
alignContent: "flex-start",
flexShrink: "0",
});
document.addEventListener("mousedown", (e) => {
if (this._weightPopover && !this._weightPopover.contains(e.target)) this._closePopover();
if (this._ctxMenu && !this._ctxMenu.contains(e.target)) this._closeCtxMenu();
});
}
// ── Snapshot helpers ───────────────────────────────────────────────────
_snapshot() {
this._undoStack.push(JSON.stringify(this.pills));
if (this._undoStack.length > 100) this._undoStack.shift();
this._redoStack = [];
}
undo() {
if (!this._undoStack.length) return;
this._redoStack.push(JSON.stringify(this.pills));
this.pills = JSON.parse(this._undoStack.pop());
this.selected.clear(); this._closePopover(); this._closeCtxMenu();
this._render(); this.onChange(this.getValue());
}
redo() {
if (!this._redoStack.length) return;
this._undoStack.push(JSON.stringify(this.pills));
this.pills = JSON.parse(this._redoStack.pop());
this.selected.clear(); this._closePopover(); this._closeCtxMenu();
this._render(); this.onChange(this.getValue());
}
// ── Mutations ──────────────────────────────────────────────────────────
_nextId() { return Math.random().toString(36).slice(2); }
addTag(text, category = "custom") {
this._snapshot();
this.pills.push({ id: this._nextId(), text, category, weight: 1.0 });
this._render(); this.onChange(this.getValue());
}
removeId(id) {
this._snapshot();
this.pills = this.pills.filter(p => p.id !== id);
this.selected.delete(id);
this._render(); this.onChange(this.getValue());
}
clear() {
this._snapshot();
this.pills = []; this.selected.clear();
this._closePopover(); this._closeCtxMenu();
this._render(); this.onChange(this.getValue());
}
toggleSelect(id) {
if (this.selected.has(id)) this.selected.delete(id);
else this.selected.add(id);
this._render();
}
joinSelected() {
const ids = [...this.selected];
if (ids.length < 2) return;
this._snapshot();
const indices = ids.map(id => this.pills.findIndex(p => p.id === id)).sort((a, b) => a - b);
const texts = indices.map(i => this.pills[i].text);
const joined = { id: this._nextId(), text: texts.join("_"), category: this.pills[indices[0]].category, weight: 1.0 };
this.pills[indices[0]] = joined;
for (let i = indices.length - 1; i >= 1; i--) this.pills.splice(indices[i], 1);
this.selected.clear(); this._render(); this.onChange(this.getValue());
}
splitSelected() {
if (this.selected.size !== 1) return;
const id = [...this.selected][0];
const idx = this.pills.findIndex(p => p.id === id);
if (idx === -1) return;
const parts = this.pills[idx].text.split("_").filter(Boolean);
if (parts.length < 2) return;
this._snapshot();
const cat = this.pills[idx].category;
this.pills.splice(idx, 1, ...parts.map(t => ({ id: this._nextId(), text: t, category: cat, weight: 1.0 })));
this.selected.clear(); this._render(); this.onChange(this.getValue());
}
setLucky(pills) {
this._snapshot();
this.pills = pills; this.selected.clear();
this._closePopover(); this._closeCtxMenu();
this._render(); this.onChange(this.getValue());
}
getValue() {
return this.pills.map(p => {
const w = Math.round(p.weight * 10) / 10;
return w !== 1.0 ? `(${p.text}:${w.toFixed(1)})` : p.text;
}).join(", ");
}
setValue(str) {
this._undoStack = []; this._redoStack = [];
this.pills = str
? str.split(/,\s*/).filter(Boolean).map(token => {
const weighted = token.match(/^\((.+):(\d+(?:\.\d+)?)\)$/);
if (weighted) return { id: this._nextId(), text: weighted[1], category: "custom", weight: parseFloat(weighted[2]) };
return { id: this._nextId(), text: token.trim(), category: "custom", weight: 1.0 };
})
: [];
this.selected.clear(); this._closePopover(); this._closeCtxMenu(); this._render();
}
// ── Context menu ───────────────────────────────────────────────────────
_closeCtxMenu() {
if (this._ctxMenu) { this._ctxMenu.remove(); this._ctxMenu = null; }
}
_openCtxMenu(pill, pillEl, e) {
this._closeCtxMenu();
this._closePopover();
const menu = document.createElement("div");
this._ctxMenu = menu;
Object.assign(menu.style, {
position: "fixed",
zIndex: "99999",
background: "#1e1e2e",
border: "1px solid #45475a",
borderRadius: "8px",
padding: "4px",
boxShadow: "0 4px 20px rgba(0,0,0,0.6)",
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: "180px",
});
const mkItem = (icon, label, onClick) => {
const item = document.createElement("div");
Object.assign(item.style, {
display: "flex",
alignItems: "center",
gap: "8px",
padding: "7px 10px",
borderRadius: "5px",
cursor: "pointer",
fontSize: "12px",
color: "#cdd6f4",
userSelect: "none",
});
const iconEl = document.createElement("span");
iconEl.textContent = icon;
const labelEl = document.createElement("span");
labelEl.textContent = label;
item.append(iconEl, labelEl);
item.addEventListener("mouseenter", () => item.style.background = "#313244");
item.addEventListener("mouseleave", () => item.style.background = "transparent");
item.addEventListener("click", () => { this._closeCtxMenu(); onClick(); });
return item;
};
// divider
const divider = document.createElement("hr");
Object.assign(divider.style, { border: "none", borderTop: "1px solid #313244", margin: "2px 0" });
// pill name header
const header = document.createElement("div");
header.textContent = pill.text;
Object.assign(header.style, {
padding: "5px 10px 3px",
fontSize: "11px",
color: "#6c7086",
fontStyle: "italic",
userSelect: "none",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
menu.appendChild(header);
menu.appendChild(divider.cloneNode());
menu.appendChild(mkItem("⚖", "Set Weight", () => this._openWeightPopover(pill, pillEl)));
if (this.onAddToList) {
menu.appendChild(mkItem("📌", "Add to Tag List", () => this.onAddToList(pill.text)));
}
document.body.appendChild(menu);
// position near cursor
const mw = 180;
let left = e.clientX + 4, top = e.clientY + 4;
if (left + mw > window.innerWidth - 8) left = e.clientX - mw - 4;
if (top + 120 > window.innerHeight - 8) top = e.clientY - 120;
menu.style.left = left + "px";
menu.style.top = top + "px";
}
// ── Weight popover ─────────────────────────────────────────────────────
_closePopover() {
if (this._weightPopover) { this._weightPopover.remove(); this._weightPopover = null; }
}
_openWeightPopover(pill, pillEl) {
this._closePopover();
const popover = document.createElement("div");
this._weightPopover = popover;
Object.assign(popover.style, {
position: "fixed", zIndex: "99999", background: "#1e1e2e",
border: "1px solid #45475a", borderRadius: "8px", padding: "10px 14px",
boxShadow: "0 4px 20px rgba(0,0,0,0.6)", display: "flex",
flexDirection: "column", gap: "8px", minWidth: "200px",
});
const label = document.createElement("div");
Object.assign(label.style, { color: "#cdd6f4", fontSize: "12px", fontWeight: "bold" });
label.textContent = `⚖ Weight: ${pill.text}`;
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", alignItems: "center", gap: "8px" });
const slider = document.createElement("input");
slider.type = "range"; slider.min = "0.1"; slider.max = "2.0"; slider.step = "0.1";
slider.value = pill.weight.toFixed(1);
Object.assign(slider.style, { flex: "1", accentColor: "#89b4fa", cursor: "pointer" });
const valueLabel = document.createElement("span");
Object.assign(valueLabel.style, { color: "#89b4fa", fontSize: "13px", fontWeight: "bold", minWidth: "28px", textAlign: "right" });
valueLabel.textContent = pill.weight.toFixed(1);
let _snapDone = false;
slider.addEventListener("mousedown", () => { _snapDone = false; });
slider.addEventListener("input", () => {
if (!_snapDone) { this._snapshot(); _snapDone = true; }
const w = parseFloat(slider.value);
pill.weight = w; valueLabel.textContent = w.toFixed(1);
this._render(); this.onChange(this.getValue());
});
const resetBtn = document.createElement("button");
resetBtn.textContent = "Reset";
Object.assign(resetBtn.style, { padding: "3px 8px", background: "#313244", color: "#cdd6f4", border: "none", borderRadius: "4px", cursor: "pointer", fontSize: "11px" });
resetBtn.addEventListener("click", () => {
this._snapshot();
pill.weight = 1.0; slider.value = "1.0"; valueLabel.textContent = "1.0";
this._render(); this.onChange(this.getValue());
});
row.append(slider, valueLabel);
popover.append(label, row, resetBtn);
document.body.appendChild(popover);
const rect = pillEl.getBoundingClientRect();
let left = rect.left, top = rect.bottom + 6;
if (left + 200 > window.innerWidth - 10) left = window.innerWidth - 210;
if (top + 110 > window.innerHeight - 10) top = rect.top - 116;
popover.style.left = left + "px";
popover.style.top = top + "px";
}
// ── Render ─────────────────────────────────────────────────────────────
_render() {
this.el.innerHTML = "";
if (this.pills.length === 0) {
const placeholder = document.createElement("span");
Object.assign(placeholder.style, { color: "#45475a", fontSize: "12px", padding: "4px" });
placeholder.textContent = "Click tags below to add them here…";
this.el.appendChild(placeholder);
return;
}
this.pills.forEach((pill, idx) => {
const selected = this.selected.has(pill.id);
const color = categoryColor(pill.category);
const hasWeight = Math.round(pill.weight * 10) / 10 !== 1.0;
const pillEl = document.createElement("div");
pillEl.draggable = true;
Object.assign(pillEl.style, {
display: "inline-flex", alignItems: "center", gap: "4px",
padding: "3px 8px", borderRadius: "12px",
background: selected ? `${color}22` : "#1e1e2e",
border: `1px solid ${selected ? color : color + "88"}`,
cursor: "grab", fontSize: "12px",
color: selected ? color : color + "cc",
userSelect: "none", transition: "all 0.1s",
});
const labelEl = document.createElement("span");
labelEl.textContent = pill.text;
labelEl.addEventListener("click", (e) => { e.stopPropagation(); this.toggleSelect(pill.id); });
if (hasWeight) {
const badge = document.createElement("span");
badge.textContent = pill.weight.toFixed(1);
Object.assign(badge.style, { fontSize: "9px", background: "#313244", color: "#89b4fa", borderRadius: "4px", padding: "1px 3px", fontWeight: "bold", lineHeight: "1.2" });
pillEl.appendChild(badge);
}
const xBtn = document.createElement("span");
xBtn.textContent = "×";
Object.assign(xBtn.style, { cursor: "pointer", color: "#f38ba8", fontWeight: "bold", fontSize: "14px", lineHeight: "1", padding: "0 2px" });
xBtn.addEventListener("click", (e) => { e.stopPropagation(); this.removeId(pill.id); });
pillEl.append(labelEl, xBtn);
// right-click → context menu
pillEl.addEventListener("contextmenu", (e) => {
e.preventDefault(); e.stopPropagation();
this._openCtxMenu(pill, pillEl, e);
});
pillEl.addEventListener("dragstart", () => { this._dragIdx = idx; });
pillEl.addEventListener("dragover", (e) => { e.preventDefault(); this._dragOver = idx; });
pillEl.addEventListener("drop", () => {
if (this._dragIdx === null || this._dragIdx === this._dragOver) return;
this._snapshot();
const moved = this.pills.splice(this._dragIdx, 1)[0];
this.pills.splice(this._dragOver, 0, moved);
this._dragIdx = this._dragOver = null;
this._render(); this.onChange(this.getValue());
});
this.el.appendChild(pillEl);
});
}
}
+236
View File
@@ -0,0 +1,236 @@
// ── Preset Storage ───────────────────────────────────────────────────────────
const PRESET_KEY = (k) => `cwk_presets_${k}`;
export function loadPresets(k) { try { return JSON.parse(localStorage.getItem(PRESET_KEY(k)) || "{}"); } catch { return {}; } }
export function savePresets(k, presets) { localStorage.setItem(PRESET_KEY(k), JSON.stringify(presets)); }
// ── Shared window builder helper ─────────────────────────────────────────────
export function makeWindow({ title, width = "500px", height = "500px", minWidth = "380px", minHeight = "300px", zIndex = "10001" }) {
const backdrop = document.createElement("div");
Object.assign(backdrop.style, { position: "fixed", inset: "0", zIndex: String(parseInt(zIndex) - 1), display: "none" });
const win = document.createElement("div");
Object.assign(win.style, {
position: "fixed", top: "120px", left: "50%", transform: "translateX(-50%)",
width, height, minWidth, minHeight,
zIndex, display: "none", flexDirection: "column",
background: "#181825", border: "1px solid #45475a", borderRadius: "10px",
boxShadow: "0 8px 40px rgba(0,0,0,0.8)", overflow: "hidden", boxSizing: "border-box",
});
// Title bar
const titleBar = document.createElement("div");
Object.assign(titleBar.style, {
height: "36px", flexShrink: "0", cursor: "move", background: "#1e1e2e",
borderBottom: "1px solid #313244", display: "flex", alignItems: "center",
padding: "0 12px", userSelect: "none", borderRadius: "10px 10px 0 0", gap: "6px",
});
["#f38ba8","#f9e2af","#a6e3a1"].forEach(c => {
const dot = document.createElement("div");
Object.assign(dot.style, { width: "10px", height: "10px", borderRadius: "50%", background: c, flexShrink: "0" });
titleBar.appendChild(dot);
});
const titleLabel = document.createElement("span");
Object.assign(titleLabel.style, { color: "#cdd6f4", fontSize: "13px", fontWeight: "bold", marginLeft: "4px", flex: "1" });
titleLabel.textContent = title;
titleBar.appendChild(titleLabel);
const closeBtn = document.createElement("div");
closeBtn.textContent = "✕";
Object.assign(closeBtn.style, { cursor: "pointer", color: "#6c7086", fontSize: "16px", lineHeight: "1", padding: "2px 6px", borderRadius: "4px", userSelect: "none" });
closeBtn.addEventListener("mouseenter", () => { closeBtn.style.color = "#cdd6f4"; closeBtn.style.background = "#f38ba820"; });
closeBtn.addEventListener("mouseleave", () => { closeBtn.style.color = "#6c7086"; closeBtn.style.background = "transparent"; });
titleBar.appendChild(closeBtn);
// Body
const body = document.createElement("div");
Object.assign(body.style, {
display: "flex", flexDirection: "column", flex: "1", minHeight: "0",
padding: "14px", gap: "10px", boxSizing: "border-box", overflowY: "auto",
});
// SE resize handle
const resizeHandle = document.createElement("div");
Object.assign(resizeHandle.style, { position: "absolute", right: "0", bottom: "0", width: "0", height: "0", borderStyle: "solid", borderWidth: "0 0 18px 18px", borderColor: "transparent transparent #45475a transparent", cursor: "nwse-resize", zIndex: "10" });
const resizeInner = document.createElement("div");
Object.assign(resizeInner.style, { position: "absolute", right: "1px", bottom: "-17px", width: "0", height: "0", borderStyle: "solid", borderWidth: "0 0 12px 12px", borderColor: "transparent transparent #89b4fa transparent" });
resizeHandle.appendChild(resizeInner);
win.appendChild(titleBar);
win.appendChild(body);
win.appendChild(resizeHandle);
document.body.appendChild(backdrop);
document.body.appendChild(win);
// Drag
let startX, startY, startL, startT;
const onDragMove = (e) => { win.style.left = (startL + e.clientX - startX) + "px"; win.style.top = (startT + e.clientY - startY) + "px"; win.style.transform = ""; };
const onDragUp = () => { document.removeEventListener("mousemove", onDragMove); document.removeEventListener("mouseup", onDragUp); document.body.style.userSelect = ""; };
titleBar.addEventListener("mousedown", (e) => {
e.preventDefault();
if (win.style.transform) { const r = win.getBoundingClientRect(); win.style.left = r.left + "px"; win.style.top = r.top + "px"; win.style.transform = ""; }
startX = e.clientX; startY = e.clientY;
startL = parseFloat(win.style.left) || 0; startT = parseFloat(win.style.top) || 0;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onDragMove); document.addEventListener("mouseup", onDragUp);
});
// Resize
let rStartX, rStartY, rStartW, rStartH;
const onResizeMove = (e) => { win.style.width = Math.max(parseInt(minWidth), rStartW + e.clientX - rStartX) + "px"; win.style.height = Math.max(parseInt(minHeight), rStartH + e.clientY - rStartY) + "px"; };
const onResizeUp = () => { document.removeEventListener("mousemove", onResizeMove); document.removeEventListener("mouseup", onResizeUp); document.body.style.userSelect = ""; };
resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault(); e.stopPropagation();
const r = win.getBoundingClientRect();
rStartX = e.clientX; rStartY = e.clientY; rStartW = r.width; rStartH = r.height;
win.style.left = r.left + "px"; win.style.top = r.top + "px"; win.style.transform = "";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onResizeMove); document.addEventListener("mouseup", onResizeUp);
});
return { win, backdrop, body, closeBtn, titleLabel };
}
// ── Preset Manager ───────────────────────────────────────────────────────────
export class PresetManager {
constructor() {
this._panelKey = null;
this._canvas = null;
const { win, backdrop, body, closeBtn } = makeWindow({ title: "📋 Manage Presets", zIndex: "10001" });
this._win = win;
this._backdrop = backdrop;
this._body = body;
closeBtn.addEventListener("click", () => this.hide());
}
show(panelKey, canvas) {
this._panelKey = panelKey;
this._canvas = canvas;
this._render();
this._backdrop.style.display = "block";
this._win.style.display = "flex";
this._win.style.left = "50%";
this._win.style.top = "120px";
this._win.style.transform = "translateX(-50%)";
}
hide() {
this._backdrop.style.display = "none";
this._win.style.display = "none";
}
_render() {
this._body.innerHTML = "";
const presets = loadPresets(this._panelKey);
const names = Object.keys(presets);
const topBar = document.createElement("div");
Object.assign(topBar.style, { display: "flex", gap: "8px", flexShrink: "0", flexWrap: "wrap" });
topBar.appendChild(this._mkBtn("📦 Export All", "#1f2d3b", () => this._exportJSON(presets, `cwk_presets_${this._panelKey}_all.json`)));
topBar.appendChild(this._mkBtn("📂 Import from JSON", "#2a1f3b", () => this._importJSON()));
this._body.appendChild(topBar);
const hr = document.createElement("hr");
Object.assign(hr.style, { border: "none", borderTop: "1px solid #313244", margin: "0", flexShrink: "0" });
this._body.appendChild(hr);
if (names.length === 0) {
const empty = document.createElement("div");
Object.assign(empty.style, { color: "#45475a", fontSize: "13px", textAlign: "center", padding: "20px 0" });
empty.textContent = "No presets saved yet.";
this._body.appendChild(empty);
return;
}
const list = document.createElement("div");
Object.assign(list.style, { display: "flex", flexDirection: "column", gap: "8px" });
names.forEach(name => {
const wrapper = document.createElement("div");
Object.assign(wrapper.style, { background: "#1e1e2e", borderRadius: "6px", border: "1px solid #313244", overflow: "hidden" });
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", alignItems: "center", gap: "6px", padding: "8px 10px" });
let folded = true;
const arrow = document.createElement("span");
arrow.textContent = "▶";
Object.assign(arrow.style, { fontSize: "10px", color: "#6c7086", cursor: "pointer", transition: "transform 0.15s", display: "inline-block", flexShrink: "0", userSelect: "none", padding: "2px 4px" });
const preview = document.createElement("div");
Object.assign(preview.style, { display: "none", padding: "8px 12px", borderTop: "1px solid #313244", fontSize: "11px", color: "#a6adc8", lineHeight: "1.6", wordBreak: "break-word", whiteSpace: "pre-wrap", background: "#11111b", maxHeight: "120px", overflowY: "auto" });
preview.textContent = presets[name] || "(empty)";
const toggleFold = () => { folded = !folded; preview.style.display = folded ? "none" : "block"; arrow.style.transform = folded ? "" : "rotate(90deg)"; arrow.style.color = folded ? "#6c7086" : "#89b4fa"; };
arrow.addEventListener("click", toggleFold);
const nameEl = document.createElement("span");
Object.assign(nameEl.style, { color: "#cdd6f4", fontSize: "13px", flex: "1", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: "pointer" });
nameEl.textContent = name;
nameEl.addEventListener("click", toggleFold);
const loadBtn = this._mkBtn("Load", "#1f3b2a", () => { this._canvas.setValue(presets[name]); this.hide(); });
const renameBtn = this._mkBtn("✏️ Rename", "#2a2a3b", () => {
const newName = window.prompt("New name:", name);
if (!newName?.trim() || newName.trim() === name) return;
const fresh = loadPresets(this._panelKey);
if (fresh[newName.trim()]) { alert("A preset with that name already exists."); return; }
fresh[newName.trim()] = fresh[name]; delete fresh[name];
savePresets(this._panelKey, fresh); this._render();
});
const exportBtn = this._mkBtn("💾 Export", "#1f2d3b", () => this._exportJSON({ [name]: presets[name] }, `cwk_preset_${name}.json`));
const delBtn = this._mkBtn("🗑 Delete", "#3b1f1f", () => {
if (!window.confirm(`Delete preset "${name}"?`)) return;
const fresh = loadPresets(this._panelKey); delete fresh[name];
savePresets(this._panelKey, fresh); this._render();
});
delBtn.style.color = "#f38ba8";
row.append(arrow, nameEl, loadBtn, renameBtn, exportBtn, delBtn);
wrapper.appendChild(row);
wrapper.appendChild(preview);
list.appendChild(wrapper);
});
this._body.appendChild(list);
}
_mkBtn(text, bg, onClick) {
const btn = document.createElement("button");
btn.textContent = text;
Object.assign(btn.style, { padding: "4px 10px", background: bg, color: "#cdd6f4", border: "none", borderRadius: "5px", cursor: "pointer", fontSize: "12px", whiteSpace: "nowrap", flexShrink: "0" });
btn.addEventListener("click", onClick);
return btn;
}
_exportJSON(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
_importJSON() {
const input = document.createElement("input"); input.type = "file"; input.accept = ".json";
input.addEventListener("change", async () => {
const file = input.files[0]; if (!file) return;
try {
const text = await file.text();
const imported = JSON.parse(text);
if (typeof imported !== "object" || Array.isArray(imported)) throw new Error("Invalid format");
const existing = loadPresets(this._panelKey);
let imported_count = 0, skipped = 0;
for (const [k, v] of Object.entries(imported)) {
if (typeof v !== "string") continue;
if (existing[k]) { skipped++; continue; }
existing[k] = v; imported_count++;
}
savePresets(this._panelKey, existing);
alert(`✅ Imported ${imported_count} preset(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ""}`);
this._render();
} catch (e) { alert("❌ Failed to import: " + e.message); }
});
input.click();
}
}
export const presetManager = new PresetManager();
+261
View File
@@ -0,0 +1,261 @@
import { categoryColor } from "./pill_canvas.js";
// ── Flat browser (quality / aesthetic / negative) ────────────────────────────
function buildFlatBrowser(tagData, onTagClick) {
const filterInput = document.createElement("input");
filterInput.type = "text";
filterInput.placeholder = "🔍 Filter tags…";
Object.assign(filterInput.style, {
width: "100%",
padding: "6px 10px",
background: "#1e1e2e",
color: "#cdd6f4",
border: "1px solid #45475a",
borderRadius: "6px",
fontSize: "13px",
boxSizing: "border-box",
marginBottom: "8px",
});
const browserContent = document.createElement("div");
Object.assign(browserContent.style, { display: "flex", flexDirection: "column", gap: "6px" });
function renderBrowser(filter = "") {
browserContent.innerHTML = "";
const q = filter.toLowerCase().trim();
for (const group of tagData) {
const groupColor = categoryColor(group.category);
for (const sub of group.subcategories) {
const matchedTags = q ? sub.tags.filter(t => t.toLowerCase().includes(q)) : sub.tags;
if (matchedTags.length === 0) continue;
let collapsed = !q;
const arrow = document.createElement("span");
arrow.textContent = "▼";
Object.assign(arrow.style, {
fontSize: "10px", transition: "transform 0.15s",
display: "inline-block", transform: collapsed ? "rotate(-90deg)" : "",
color: groupColor,
});
const header = document.createElement("div");
Object.assign(header.style, {
display: "flex", alignItems: "center", gap: "6px", cursor: "pointer",
padding: "4px 6px", borderRadius: "4px", background: "#1e1e2e",
fontSize: "12px", fontWeight: "bold", userSelect: "none",
borderLeft: `3px solid ${groupColor}`,
});
const catLabel = document.createElement("span");
catLabel.textContent = `${group.category} › ${sub.name}`;
catLabel.style.color = groupColor;
const countLabel = document.createElement("span");
countLabel.textContent = `(${matchedTags.length})`;
Object.assign(countLabel.style, { color: "#6c7086", fontWeight: "normal" });
header.append(arrow, catLabel, countLabel);
const tagsEl = document.createElement("div");
Object.assign(tagsEl.style, {
display: collapsed ? "none" : "flex",
flexWrap: "wrap", gap: "4px", padding: "4px 4px 4px 16px",
});
matchedTags.forEach(tag => {
const chip = document.createElement("div");
chip.textContent = tag;
Object.assign(chip.style, {
padding: "2px 8px", borderRadius: "10px", background: "#1e1e2e",
border: `1px solid ${groupColor}88`, color: groupColor + "cc",
fontSize: "11px", cursor: "pointer", userSelect: "none", transition: "all 0.1s",
});
chip.addEventListener("mouseenter", () => { chip.style.background = groupColor + "22"; chip.style.borderColor = groupColor; chip.style.color = groupColor; });
chip.addEventListener("mouseleave", () => { chip.style.background = "#1e1e2e"; chip.style.borderColor = groupColor + "88"; chip.style.color = groupColor + "cc"; });
chip.addEventListener("click", () => onTagClick(tag, group.category));
tagsEl.appendChild(chip);
});
header.addEventListener("click", () => {
collapsed = !collapsed;
tagsEl.style.display = collapsed ? "none" : "flex";
arrow.style.transform = collapsed ? "rotate(-90deg)" : "";
});
browserContent.appendChild(header);
browserContent.appendChild(tagsEl);
}
}
}
renderBrowser();
filterInput.addEventListener("input", () => renderBrowser(filterInput.value));
const wrapper = document.createElement("div");
Object.assign(wrapper.style, { display: "flex", flexDirection: "column" });
wrapper.append(filterInput, browserContent);
return wrapper;
}
// ── 3-level nested browser (main panel) ──────────────────────────────────────
function buildNestedBrowser(tagData, onTagClick) {
const filterInput = document.createElement("input");
filterInput.type = "text";
filterInput.placeholder = "🔍 Filter tags…";
Object.assign(filterInput.style, {
width: "100%",
padding: "6px 10px",
background: "#1e1e2e",
color: "#cdd6f4",
border: "1px solid #45475a",
borderRadius: "6px",
fontSize: "13px",
boxSizing: "border-box",
marginBottom: "8px",
});
const browserContent = document.createElement("div");
Object.assign(browserContent.style, { display: "flex", flexDirection: "column", gap: "4px" });
function renderBrowser(filter = "") {
browserContent.innerHTML = "";
const q = filter.toLowerCase().trim();
for (const group of tagData) {
const groupColor = categoryColor(group.category);
// ── count total matching tags across all subcategories ────────
const totalMatch = group.subcategories.reduce((n, s) => {
return n + (q ? s.tags.filter(t => t.toLowerCase().includes(q)).length : s.tags.length);
}, 0);
if (totalMatch === 0) continue;
// ── Level 1 : Category header ─────────────────────────────────
let catCollapsed = !q;
const catArrow = document.createElement("span");
catArrow.textContent = "▼";
Object.assign(catArrow.style, {
fontSize: "10px", transition: "transform 0.15s", display: "inline-block",
transform: catCollapsed ? "rotate(-90deg)" : "", color: groupColor,
});
const catLabel = document.createElement("span");
catLabel.textContent = group.category;
catLabel.style.color = groupColor;
const catCount = document.createElement("span");
catCount.textContent = `(${totalMatch})`;
Object.assign(catCount.style, { color: "#6c7086", fontWeight: "normal", fontSize: "11px" });
const catHeader = document.createElement("div");
Object.assign(catHeader.style, {
display: "flex", alignItems: "center", gap: "6px", cursor: "pointer",
padding: "5px 8px", borderRadius: "6px", background: "#1e1e2e",
fontSize: "12px", fontWeight: "bold", userSelect: "none",
borderLeft: `3px solid ${groupColor}`,
});
catHeader.append(catArrow, catLabel, catCount);
// ─��� Level 1 body (holds all subcategories) ────────────────────
const catBody = document.createElement("div");
Object.assign(catBody.style, {
display: catCollapsed ? "none" : "flex",
flexDirection: "column",
gap: "3px",
paddingLeft: "12px",
marginBottom: "2px",
});
catHeader.addEventListener("click", () => {
catCollapsed = !catCollapsed;
catBody.style.display = catCollapsed ? "none" : "flex";
catArrow.style.transform = catCollapsed ? "rotate(-90deg)" : "";
});
// ── Level 2 : Subcategory headers ─────────────────────────────
for (const sub of group.subcategories) {
const matchedTags = q ? sub.tags.filter(t => t.toLowerCase().includes(q)) : sub.tags;
if (matchedTags.length === 0) continue;
let subCollapsed = !q;
const subArrow = document.createElement("span");
subArrow.textContent = "▼";
Object.assign(subArrow.style, {
fontSize: "9px", transition: "transform 0.15s", display: "inline-block",
transform: subCollapsed ? "rotate(-90deg)" : "", color: groupColor + "aa",
});
const subLabel = document.createElement("span");
subLabel.textContent = sub.name;
Object.assign(subLabel.style, { color: groupColor + "bb" });
const subCount = document.createElement("span");
subCount.textContent = `(${matchedTags.length})`;
Object.assign(subCount.style, { color: "#6c7086", fontWeight: "normal", fontSize: "10px" });
const subHeader = document.createElement("div");
Object.assign(subHeader.style, {
display: "flex", alignItems: "center", gap: "5px", cursor: "pointer",
padding: "3px 6px", borderRadius: "4px", background: "#181825",
fontSize: "11px", fontWeight: "bold", userSelect: "none",
borderLeft: `2px solid ${groupColor}55`,
});
subHeader.append(subArrow, subLabel, subCount);
// ── Level 3 : Tags ─────────────────────────────────────────
const tagsEl = document.createElement("div");
Object.assign(tagsEl.style, {
display: subCollapsed ? "none" : "flex",
flexWrap: "wrap",
gap: "4px",
padding: "4px 4px 4px 12px",
});
matchedTags.forEach(tag => {
const chip = document.createElement("div");
chip.textContent = tag;
Object.assign(chip.style, {
padding: "2px 8px", borderRadius: "10px", background: "#1e1e2e",
border: `1px solid ${groupColor}66`, color: groupColor + "aa",
fontSize: "11px", cursor: "pointer", userSelect: "none", transition: "all 0.1s",
});
chip.addEventListener("mouseenter", () => { chip.style.background = groupColor + "22"; chip.style.borderColor = groupColor; chip.style.color = groupColor; });
chip.addEventListener("mouseleave", () => { chip.style.background = "#1e1e2e"; chip.style.borderColor = groupColor + "66"; chip.style.color = groupColor + "aa"; });
chip.addEventListener("click", () => onTagClick(tag, group.category));
tagsEl.appendChild(chip);
});
subHeader.addEventListener("click", () => {
subCollapsed = !subCollapsed;
tagsEl.style.display = subCollapsed ? "none" : "flex";
subArrow.style.transform = subCollapsed ? "rotate(-90deg)" : "";
});
catBody.appendChild(subHeader);
catBody.appendChild(tagsEl);
}
browserContent.appendChild(catHeader);
browserContent.appendChild(catBody);
}
}
renderBrowser();
filterInput.addEventListener("input", () => renderBrowser(filterInput.value));
const wrapper = document.createElement("div");
Object.assign(wrapper.style, { display: "flex", flexDirection: "column" });
wrapper.append(filterInput, browserContent);
return wrapper;
}
// ── Public entry point ────���───────────────────────────────────────────────────
export function buildTagBrowser(tagData, panelKey, onTagClick) {
return panelKey === "main"
? buildNestedBrowser(tagData, onTagClick)
: buildFlatBrowser(tagData, onTagClick);
}
+195
View File
@@ -0,0 +1,195 @@
import { makeWindow } from "./preset_manager.js";
// ── Add-to-Tag-List panel ────────────────────────────────────────────────────
class TagEditor {
constructor() {
const { win, backdrop, body, closeBtn } = makeWindow({
title: "📌 Add Tag to List",
width: "420px",
height: "auto",
minWidth: "340px",
minHeight: "200px",
zIndex: "10002",
});
this._win = win;
this._backdrop = backdrop;
this._body = body;
// auto height
Object.assign(this._win.style, { height: "auto" });
closeBtn.addEventListener("click", () => this.hide());
this._resolve = null;
}
// tagData = TAG_DATA[panelKey], panelKey for the POST
show(tag, tagData, panelKey) {
this._body.innerHTML = "";
this._panelKey = panelKey;
const mkLabel = (text) => {
const l = document.createElement("label");
l.textContent = text;
Object.assign(l.style, { color: "#a6adc8", fontSize: "12px", fontWeight: "bold", marginBottom: "2px" });
return l;
};
const mkSelect = () => {
const s = document.createElement("select");
Object.assign(s.style, {
padding: "6px 10px",
background: "#1e1e2e",
color: "#cdd6f4",
border: "1px solid #45475a",
borderRadius: "6px",
fontSize: "13px",
cursor: "pointer",
width: "100%",
});
return s;
};
// ── Tag name ───────────────────────────────────────────────────────
const tagInput = document.createElement("input");
tagInput.type = "text";
tagInput.value = tag;
Object.assign(tagInput.style, {
padding: "6px 10px",
background: "#1e1e2e",
color: "#cdd6f4",
border: "1px solid #45475a",
borderRadius: "6px",
fontSize: "13px",
width: "100%",
boxSizing: "border-box",
});
// ── Category select ────────────────────────────────────────────────
const catSelect = mkSelect();
tagData.forEach(group => {
const opt = document.createElement("option");
opt.value = group.category;
opt.textContent = group.category;
catSelect.appendChild(opt);
});
// ── Subcategory select ─────────────────────────────────────────────
const subSelect = mkSelect();
const populateSubs = (categoryName) => {
subSelect.innerHTML = "";
const group = tagData.find(g => g.category === categoryName);
if (!group) return;
group.subcategories.forEach(sub => {
const opt = document.createElement("option");
opt.value = sub.name;
opt.textContent = `${sub.name} (${sub.tags.length})`;
subSelect.appendChild(opt);
});
};
populateSubs(catSelect.value);
catSelect.addEventListener("change", () => populateSubs(catSelect.value));
// ── Status message ─────────────────────────────────────────────────
const status = document.createElement("div");
Object.assign(status.style, { fontSize: "12px", minHeight: "18px", textAlign: "center" });
// ── Buttons ────────────────────────────────────────────────────────
const confirmBtn = document.createElement("button");
confirmBtn.textContent = "📌 Add Tag";
Object.assign(confirmBtn.style, {
padding: "8px 20px", background: "#89b4fa", color: "#1e1e2e",
border: "none", borderRadius: "6px", cursor: "pointer",
fontWeight: "bold", fontSize: "13px", flex: "1",
});
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
Object.assign(cancelBtn.style, {
padding: "8px 20px", background: "#313244", color: "#cdd6f4",
border: "none", borderRadius: "6px", cursor: "pointer",
fontSize: "13px", flex: "1",
});
cancelBtn.addEventListener("click", () => this.hide());
confirmBtn.addEventListener("click", async () => {
const tagVal = tagInput.value.trim();
const catVal = catSelect.value;
const subVal = subSelect.value;
if (!tagVal) { status.textContent = "⚠️ Tag name cannot be empty."; status.style.color = "#f9e2af"; return; }
confirmBtn.disabled = true;
confirmBtn.textContent = "Saving…";
status.textContent = "";
try {
const res = await fetch("/cwk/add_tag", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ panelKey: this._panelKey, category: catVal, subcategory: subVal, tag: tagVal }),
});
const json = await res.json();
if (json.ok) {
status.style.color = "#a6e3a1";
status.textContent = `✅ "${tagVal}" added to ${catVal} › ${subVal}`;
confirmBtn.textContent = "📌 Add Tag";
confirmBtn.disabled = false;
// brief pause then close
setTimeout(() => this.hide(), 1200);
} else if (json.duplicate) {
status.style.color = "#f9e2af";
status.textContent = `⚠️ Already exists in ${json.where}`;
confirmBtn.textContent = "📌 Add Tag";
confirmBtn.disabled = false;
} else {
status.style.color = "#f38ba8";
status.textContent = `❌ ${json.error}`;
confirmBtn.textContent = "📌 Add Tag";
confirmBtn.disabled = false;
}
} catch (e) {
status.style.color = "#f38ba8";
status.textContent = `❌ Network error: ${e.message}`;
confirmBtn.textContent = "📌 Add Tag";
confirmBtn.disabled = false;
}
});
const btnRow = document.createElement("div");
Object.assign(btnRow.style, { display: "flex", gap: "8px" });
btnRow.append(confirmBtn, cancelBtn);
// ── Layout ─────────────────────────────────────────────────────────
const grid = document.createElement("div");
Object.assign(grid.style, { display: "flex", flexDirection: "column", gap: "10px" });
const tagGroup = document.createElement("div");
Object.assign(tagGroup.style, { display: "flex", flexDirection: "column", gap: "4px" });
tagGroup.append(mkLabel("Tag"), tagInput);
const catGroup = document.createElement("div");
Object.assign(catGroup.style, { display: "flex", flexDirection: "column", gap: "4px" });
catGroup.append(mkLabel("Category"), catSelect);
const subGroup = document.createElement("div");
Object.assign(subGroup.style, { display: "flex", flexDirection: "column", gap: "4px" });
subGroup.append(mkLabel("Subcategory"), subSelect);
grid.append(tagGroup, catGroup, subGroup, status, btnRow);
this._body.appendChild(grid);
this._backdrop.style.display = "block";
this._win.style.display = "flex";
this._win.style.left = "50%";
this._win.style.top = "160px";
this._win.style.transform = "translateX(-50%)";
}
hide() {
this._backdrop.style.display = "none";
this._win.style.display = "none";
}
}
export const tagEditor = new TagEditor();
+13
View File
@@ -0,0 +1,13 @@
[
{
"category": "Aesthetic",
"subcategories": [
{ "name": "Framing", "tags": ["aerial","ass_focus","between_breasts","between_fingers","between_legs","bust","cinematic_angle","close-up","cowboy_shot","dutch_angle","eyes_focus","face_focus","fisheye","foot_focus","foreshortening","from_above","from_back","from_behind","from_below","from_outside","from_side","front_view","full-length_portrait","full_body","full_shot","half-length_portrait","head_and_shoulders_portrait","head_portrait","hip_focus","landscape","macro_shot","medium_shot","midriff_peek","mirror_selfie","multiple_views","panorama","panty_peek","pantyshot","peeking","phone_screen","portrait","profile","pussy_peek","solo_focus","three-quarter_view","upper_body","upskirt","vehicle_focus","wide_shot","x-ray"] },
{ "name": "Art Style", "tags": ["16mm","35mm","3d_render","anime","cartoon","collage","digital_art","dreamworks","hyper_realism","illustration","imax","lomography","manga","oil_painting","panavision","photography","pixar","vector_art","watercolor"] },
{ "name": "Era", "tags": ["ancient","ancient_china","ancient_japan","art_deco","cyberpunk","futuristic","medieval","modern_china","renaissance","retro","steampunk","vintage"] },
{ "name": "Mood", "tags": ["dark","dreamy","ethereal","gritty","joyful","melancholic","romantic","surreal","whimsical"] },
{ "name": "Color Palette", "tags": ["cool_colors","golden_hour","monochrome","neon_colors","pastel_colors","sepia","vibrant_colors","warm_colors"] },
{ "name": "Lighting", "tags": ["backlighting","blue_light","cinematic_lighting","dramatic_lighting","golden_light","neon_light","rim_light","soft_lighting"] }
]
}
]
+48
View File
@@ -0,0 +1,48 @@
{
"subject": [
"1girl", "1boy", "1woman", "1man", "young woman", "young man",
"fantasy warrior", "cyberpunk hacker", "space explorer", "medieval knight",
"witch", "wizard", "elf", "android", "detective", "ninja", "samurai",
"pirate", "scientist", "artist", "musician", "dancer", "archer"
],
"clothing": [
"school uniform", "armor", "casual outfit", "kimono", "sundress",
"leather jacket", "hoodie", "elegant dress", "military uniform",
"fantasy robe", "cyberpunk suit", "Victorian dress", "tracksuit",
"trench coat", "crop top and jeans", "traditional hanbok", "wedding dress",
"space suit", "cloak", "priest robes", "maid outfit", "business suit"
],
"expression": [
"smiling", "serious", "surprised", "shy", "confident", "sad",
"angry", "laughing", "blushing", "focused", "dreamy", "nervous",
"curious", "determined", "melancholic", "joyful", "stoic", "winking"
],
"action": [
"standing", "sitting", "running", "looking at viewer", "walking",
"reading a book", "holding a sword", "casting a spell", "playing guitar",
"leaning against a wall", "arms crossed", "reaching out", "jumping",
"looking away", "lying down", "drinking coffee", "writing", "dancing",
"meditating", "drawing", "looking out a window", "holding a flower"
],
"environment": [
"forest", "cyberpunk city at night", "beach at sunset", "ancient library",
"snowy mountain", "futuristic space station", "medieval castle", "coffee shop",
"cherry blossom park", "underwater ruins", "desert oasis", "haunted mansion",
"floating sky islands", "neon-lit alley", "cozy bedroom", "rooftop at night",
"misty lake", "volcano crater", "crystal cave", "bamboo forest",
"abandoned factory", "magical garden", "train station", "cathedral"
],
"nsfw_subject": [
"1girl", "1boy", "1woman", "1man", "two girls", "two boys",
"elf woman", "android", "succubus", "vampire woman", "catgirl", "foxgirl"
],
"nsfw_clothing": [
"lingerie", "bikini", "partially undressed", "see-through dress",
"revealing outfit", "open shirt", "stockings and garter belt",
"naked", "topless", "underwear only"
],
"nsfw_action": [
"posing seductively", "lying on bed", "looking at viewer seductively",
"undressing", "embracing", "kissing", "sitting on lap"
]
}
+68
View File
@@ -0,0 +1,68 @@
[
{
"category": "Characters",
"subcategories": [
{ "name": "Subject", "tags": ["1boy_solo","1girl","1girl_solo","2boys","2girls","3boys","3girls","multiple_boys","multiple_girls"] },
{ "name": "Gender", "tags": ["androgynous","boy","feminine","girl","man","masculine","non-binary","woman"] },
{ "name": "Age", "tags": ["adult","aged","child","elderly","loli","mature","old","teenager","young","young_adult"] },
{ "name": "Ethnicity", "tags": ["Chinese","Japanese","Korean","african","asian","caucasian","diverse","indian","middle_eastern","mixed_race"] },
{ "name": "Body Type", "tags": ["athletic","average_build","curvy","muscular","petite","slender","slim","tall","thick"] },
{ "name": "Facial Features", "tags": ["Ears_that_stick_out","asian_eyes","beautiful_face","delicate_features","expressive_face","large_ears","large_eyes","large_mouth","sharp_features","small_ears","small_eyes","small_mouth","soft_features","symmetrical_face"] },
{ "name": "Breasts & Nipples", "tags": ["asian_nipples","dark_nipples","flat_chest","hanging_breasts","huge_breasts","large_breasts","large_nipples","medium_breasts","nipples","perky_breasts","small_breasts","small_nipples"] },
{ "name": "Hair Style", "tags": ["bangs","bob_cut","braid","chignon","cornrows","curly_bob","curly_frizzy_hair","double_buns_","dragon_phoenix_bun_","dreadlocks_","hanfu_hairstyle_","high_ponytail","hime_cut_","long_curly_hair","long_straight_hair","long_twintails","loose_curly_afro","low_ponytail","messy_bun_","pixie_cut","short_straight_hair","short_wavy_hair","side_braid","tomboy_cut","twin_braids","twintails","updo"] },
{ "name": "Lips", "tags": ["black_lips","glossy_lips","pink_lips","pulpy_lips","purple_lips","red_lips","rose-red_lips","thick_lips","thin_lips"] },
{ "name": "Eyes Color", "tags": ["albinos_eyes","black_eyes","blue_eyes","brown_eyes","chestnut_eyes","green_eyes","lavender_eyes","orange_eyes","red_eyes","silver_eyes"] },
{ "name": "Profession", "tags": ["acrobat","assassin","astronaut","athletes","ballerina","barista","boxer","bride","cashier","cheerleader","chef","cleric","coser","dancer","detective","diver","doll","firefighter","flight_stewardess","goalkeeper","harem","idol","king","knight","lifeguard","magician","maid","mailman","makihitsuji","miko","motorcyclist","nun","nurse","office lady","pilot","police","priest","queen","race_queen","racer","samurai","scientist","soldier","student","teacher","wa_maid","waitress","witch"] }
]
},
{
"category": "Clothing",
"subcategories": [
{ "name": "Nudity", "tags": ["bottomless","completely_nude","nude_breasts","partial_nudity","topless","unclothed","unclothing"] },
{ "name": "Casual", "tags": ["athleisure","casual_wear","hoodie","jeans","nightgown","nightie","pajamas","street_wear","sweater","t-shirt"] },
{ "name": "Uniform / Sport / Profession", "tags": ["aviator_uniform","band_uniform","baseball_uniform","business_suit","cheerleader","chef_uniform","fireman_uniform","flight-stewardess_uniform","gym_uniform","military_uniform","nurse_uniform","police_uniform","school_uniform","space_suit","tennis_uniform","volleyball_uniform"] },
{ "name": "Tops", "tags": ["aran_sweater","blouse","bomber_jacket","camisole","cardigan","casual_tshirt","collared_shirt","crop_top","cropped_shirt","dress_shirt","hawaiian_shirt","hoodie","jacket","lab_coat","polo_shirt","pullover","sailor_shirt","shirt","sportbra","sweater","sweatshirt","tank_top","taoist_coat","tshirt","undershirt","vest","windbreaker","zip-up_hoodie","pullover_hoodie","crewneck_sweatshirt"] },
{ "name": "Bottoms", "tags": ["a-line_skirt","ankle-length_skirt","ankle_pants","asymmetrical_skirt","baggy_pants","ball_gown_skirt","bell_bottoms","biker_pants","bikini_panties","bootcut_pants","boyfriend_jeans","boyshorts","briefs","bubble_skirt","cargo_pants","cargo_skirt","carpenter_pants","cheeky_panties","cheongsam_skirt","chiffon_skirt","chinos","cigarette_pants","control_panties","corduroy_pants","cotton_panties","crop_pants","culottes","cycling_pants","dance_belt","dress_pants","flared_pants","flared_skirt","full_coverage_panties","g-string","gaucho_pants","godet_skirt","harem_pants","high-cut_panties","high-low_skirt","high-waisted_panties","high-waisted_pants","hiphugger_panties","hot_pants","jodhpurs","joggers","karate_pants","khakis","kilt","leggings","loincloth","long_johns","loose-fit_pants","maxi_skirt","mesh_panties","micro_panties","micro_skirt","midi_skirt","mini_skirt","mom_jeans","no-show_panties","palazzo_pants","pantyhose","paperbag_pants","parachute_pants","peg-leg_pants","pencil_pants","pencil_skirt","peplum_skirt","pleated_pants","pleated_skirt","riding_pants","sailor_pants","sarong_skirt","seamless_panties","shapewear","skater_skirt","skinny_jeans","slim-fit_pants","slit_skirt","snow_pants","sport_panties","straight_skirt","string_bikini","sweatpants","tanga_panties","tapered_pants","tartan_skirt","thong","tiered_skirt","track_pants","trousers","trunks","tulip_skirt","tummy_control_panties","tutu_skirt","underwear_shorts","v-string","wide-leg_pants","womens_boxers","wrap_skirt","yoga_pants"] },
{ "name": "Clothes Style", "tags": ["asymmetric","backless_back_cutout","bell_sleeves","criss-cross_halter","frilled","fur-trimmed","high-low_hem","lace-trimmed","layered","off-shoulder","one-shoulder","peplum","pleated","puffed_sleeves","ribbon-trimmed","ruffled","see-through_transparent","short_sleeve","sleeveless","sling_strap_suspender","strapless","taut_tight","turtleneck","unbuttoned_open_clothes","wrapped"] },
{ "name": "Ornamentation", "tags": ["Camouflage_pattern","Paisley_pattern","argyle_pattern","checked-pattern","chevron_pattern","cloud_pattern","coin_pattern","diamond_pattern","dotted_pattern","floral_pattern","geometric_pattern","golden_sun_pattern","gradient_pattern","herringbone_pattern","hundstooth_pattern","ikat_pattern","liberty_pattern","lighting_pattern","moon_pattern","multi-colored","pheonix_pattern","plaid","polka_dot_pattern","quatrefoil_pattern","ribbed","rose_pattern","silver_star_pattern","striped_pattern","swirl_pattern","tartan_pattern","tie-dye_pattern","triangle_pattern","wave_pattern","zigzag_pattern"] },
{ "name": "Dress", "tags": ["armored_dress","ballgown","bohemian_dress","business_casual_dress","business_dress","business_formal_dress","business_professional_dress","casual_chic_dress","casual_dress","club_dress","cocktail_dress","edgy_dress","evening_gown","formal_dress","gothic_dress","gown","highleg_dress","hipster_dress","jumpsuit","long_dress","party_dress","pencil_dress","preppy_dress","punk_dress","retro_dress","sailor_dress","short_dress","short_sweater_dress","smart_casual_dress","sporty_dress","streetwear_dress","sundress","vintage_dress","wedding_dress"] },
{ "name": "Swimsuits", "tags": ["bikini","japanese_school_swimsuit","monokini","one-piece_swimsuit","skirted_swimsuit","slingshot_swimsuit","swim_dress","tankini","wetsuit"] },
{ "name": "Asian Clothes", "tags": ["aikidogi","ao_dai","cheongsam","gakuran","hadagi","hagoromo","hanbok","hanfu","japanese_school_uniform","jinbei","judogi","jujutsugi","karategi","kendogi","kimono","kimono_robe","nagajuban","nagajuban_robe","samue","yukata"] },
{ "name": "Japanese Tops", "tags": ["Hanten","Haori","happi","michiyuki","tanzen","tombi_coat"] },
{ "name": "Japanese Bottoms", "tags": ["Hakama","fundoshi","maekake","tobi_pants"] },
{ "name": "Japanese Accessories & Shoes", "tags": ["geta","hachimaki","obi","obiage","obidome","obijime","setta","tabi","tenugui","zori"] }
]
},
{
"category": "Action",
"subcategories": [
{ "name": "Body Posture / Pose", "tags": ["attacking","bathing","battle_stance","bent_over","bowing","casting_spell","climbing","combat_ready","cooking","dancing","defending","drawing","drawing_bow","drinking","driving","dynamic_pose","exercise","fetal_position","fighting","firing","fishing","flying","high_five","holding_hands","hugging","jumping","kissing","leaning_back","leaning_forward","leaning_to_the_side","licking_lips","lip_biting","looking_back","lying_down","lying_on_back","lying_on_grass","lying_on_side","lying_on_stomach","playing_instrument","pointing","pole_dancing","reading","riding","running","showering_shower_caddy","singing","sitting","smoking","standing","stretching","swimming","talking","talking_on_phone","using_weapon","walking","waving","writing"] },
{ "name": "Hand Movements", "tags": ["adjusting_eyewear","adjusting_hair","arm_above_head","arm_at_side","arm_behind_back","arm_behind_head","arm_up","arms_at_sides","arms_behind_back","arms_behind_head","arms_crossed","arms_up","beckoning","bunching_hair","caramelldansen","cheek_pinching","cheek_poking","chin_rest","chin_stroking","clapping","claw_pose","covering_eyes","covering_face","covering_mouth","crossed_arms","crossed_fingers","double_v","finger_biting","finger_gun","finger_in_mouth","finger_to_mouth","fingers_together","fist","hair_pull","hair_twirling","hand_in_hair","hand_in_pocket","hand_on_chest","hand_on_head","hand_on_hip","hand_on_own_cheek","hand_on_own_chin","hand_on_own_stomach","hand_to_mouth","hands_clasped","hands_on_hips","hands_on_lap","hands_on_own_cheeks","hands_on_own_face","hands_on_own_head","hands_on_own_knees","hands_on_own_stomach","hands_on_own_thighs","head_rest","heart_sig","index_finger_raised","middle_finger","ok_sign","outstretched_arms","palms_together","paw_pose","peace_sign","pointing_at_self","pointing_at_viewer","pointing_up","poking","praying","reaching_out","salute","shading_eyes","shushing","snapping_fingers","spread_fingers","thumbs_up","v","v_arms","w","w_arms","waving","wiping_tear"] },
{ "name": "Leg Movements", "tags": ["all_fours","butterfly_sitting","crossed_ankles","crossed_legs","curtsy","folded","high_kick","hugging_own_legs","kicking","knee_up","kneeling","knees_to_chest","knees_together_feet_apart","leg_up","legs_apart","legs_over_head","legs_together","legs_up","lotus_position","m_legs","one_knee","open_stance","pigeon-toed","sidesaddle","sitting","sitting_backwards","sitting_in_tree","sitting_on_bed","sitting_on_chair","sitting_on_desk","sitting_on_railing","sitting_on_rock","sitting_on_stairs","sitting_on_table","sitting_on_water","spread_legs","squatting","squatting_open_legs","standing_split_leg_up","straddling","tiptoes","toe-point","upright_straddle","wariza","yokozuwari"] },
{ "name": "Glance", "tags": ["head_down","head_tilt","looking_afar","looking_ahead","looking_at_viewer","looking_away","looking_down","looking_over_shoulder","looking_to_the_side","looking_up"] },
{ "name": "Expression", "tags": ["angry","annoyed","blush","confused","contempt","crazy_smile","crying_with_eyes_open","evil_smile","flustered","frustrated","furrowed_brow","giggling","glaring","grin","happy_tears","laughing","moaning","neutral","sad","scared","screaming","seductive_smile","serious","shouting","shy","smile","smiling","smirk","stare","surprised","wink"] }
]
},
{
"category": "Environment",
"subcategories": [
{ "name": "Nature", "tags": ["a_lawn_covered_with_petals","beach","botanical_garden","desert","forest","garden","jungle","meadow","mountain","park","river","treehouse","valley","waterfall"] },
{ "name": "Urban", "tags": ["airport","alley","amusement_park","arena","avenue","bridge","bus_stop","castle","cathedral","church","city","cityscape","cyberpunk_city","downtown","east_asian_architecture","ferris_wheel","office_building","fountain","graveyard","harbor","industrial_area","japanese_garden","playground","railroad_tracks","rooftop","ruins","skyscraper","stadium","street","subway","swimming_pool","train_station","tunnel","under_bridge","urban_setting","waterpark"] },
{ "name": "Interior", "tags": ["airplane_interior","aquarium","art_gallery","art_room","auditorium","bakery","bar","basement","bathhouse","bathroom","bed","bedroom","bench","bench_seats_in_the_train","bowling_alley","cafe","casino","classroom","clubroom","coffee_house","concert_hall","convenience_store","courtroom","dining_hall","dormitory","dungeon","electrical_room","engine_room","greenhouse","gym","hospital","hotel","infirmary","intersection","izakaya","kitchen","laboratory","library","market_stall","metro_station","mosque","museum","onsen","pharmacy","planetarium","prison","reservoir","restaurant","school","server_room","shop","stage","supermarket","temple","theater","throne_room","warehouse","workshop"] },
{ "name": "Fantasy", "tags": ["dragon_lair","enchanted_forest","fantasy_world","magical_realm","mystical_landscape","wizard_tower"] },
{ "name": "Weather", "tags": ["aurora","cloudy","dawn","foggy","golden_hour","night","overcast","rainy","snowy","stormy","sunny","sunrise","sunset"] }
]
},
{
"category": "NSFW",
"subcategories": [
{ "name": "Stimulation", "tags": ["footjob","double_footjob","implied_footjob","cooperative_footjob","shoejob","bootjob","feet","licking_foot","foot_worship","smelling_feet","frottage","armpit_sex","grinding","backjob","buttjob","cooperative_grinding","kneepit_sex","paizuri","autopaizuri","cooperative_paizuri","handsfree_paizuri","naizuri","paizuri_over_clothes","paizuri_on_lap","paizuri_under_clothes","pecjob","perpendicular_paizuri","stealth_paizuri","straddling_paizuri","thigh_sex","glansjob","cloth_glansjob","groping","grabbing_another's_ass","grabbing_another's_breast","guided_breast_grab","pectoral_grab","guided_pectoral_grab","nipple_tweak","crotch_grab","guided_crotch_grab","grabbing_own_breast","torso_grab","hairjob","handjob","caressing_testicles","double_handjob","cooperative_handjob","nursing_handjob","cuddling_handjob","reverse_nursing_handjob","reach-around","two-handed_handjob","masturbation","clothed_masturbation","crotch_rub","building_sex","pillow_humping","teddy_bear_sex","table_humping","female_masturbation","futanari_masturbation","implied_masturbation","male_masturbation","mutual_masturbation","stealth_masturbation","tail_masturbation","tail","tail_insertion","tailjob","oral","anilingus","rusty_trombone","breast_sucking","cunnilingus","autocunnilingus","implied_cunnilingus","fellatio","autofellatio","cum_swap","deepthroat","implied_fellatio","irrumatio","cooperative_fellatio","multiple_penis_fellatio","hug_and_suck","licking_testicle","sitting_on_face","testicle_sucking"] },
{ "name": "Group Sex", "tags": ["group_sex","bisexual","bisexual_female","bisexual_male","oral_sandwich","daisy_chain","gangbang","double_penetration","triple_penetration","love_train","cooperative_fellatio","cooperative_footjob","cooperative_breast_smother","orgy","reverse_spitroast","spitroast","teamwork","threesome","MMF_threesome","FFM_threesome","MMM_threesome","FFF_threesome"] },
{ "name": "Penetration", "tags": ["animal_insertion","cervical_penetration","covered_penetration","deep_penetration","food_insertion","inflation","cum_inflation","big_belly","enema","large_insertion","stomach_bulge","male_penetrated","multiple_insertions","nipple_penetration","nosejob","object_insertion","vaginal_object_insertion","anal_object_insertion","urethral_insertion","sounding","fingering","anal_fingering","prostate_milking","fingering_through_clothes","fingering_through_panties","implied_fingering","fisting","anal_fisting","self_fisting","sex","after_sex","after_anal","after_buttjob","after_fellatio","after_fingering","after_frottage","after_insertion","after_masturbation","after_oral","after_paizuri","after_rape","after_urethral","after_vaginal","afterglow","clothed_after_sex","anal","double_anal","imminent_anal","pegging","triple_anal","clothed_sex","guided_penetration","happy_sex","imminent_penetration","implied_sex","navel_sex","rough_sex","sex_from_behind","skull_fucking","ear_sex","penis_in_eye","tentacle_sex","underwater_sex","vaginal","double_vaginal","imminent_vaginal","triple_vaginal"] },
{ "name": "Same Sex Acts","tags": ["yaoi","bara","implied_yaoi","bulges_touching","penises_touching","testicles_touching","yuri","implied_yuri","tribadism"] },
{ "name": "Cum", "tags": ["cum","bukkake","cumdump","cum_bath","cumdrip","cum_pool","cum_inflation","cum_in_ass","cum_in_clothes","cum_in_cup","cum_in_mouth","cum_in_throat","cum_on_tongue","cum_in_pussy","cum_in_urethra","cum_on_body","cum_on_armpits","cum_on_ass","cum_on_back","cum_on_breasts","cum_on_chest","cum_on_feet","cum_on_fingers","cum_on_hair","cum_on_pectorals","cum_on_pussy","cum_on_stomach","cum_in_navel","cum_on_clothes","cum_on_eyewear","cum_on_food","ejaculation","ejaculating_while_penetrated","pull_out","facial","autofacial","felching","gokkun"] },
{ "name": "Exhibitionism","tags": ["exhibitionism","clothed_sex","public_indecency","public_nudity","zenra","public_vibrator","stealth_bondage","stealth_masturbation","stealth_paizuri","stealth_sex","cheating","netorare","reverse_netorare","prostitution","public_use","voyeurism","caught","peeking","walk-in"] },
{ "name": "Expressions", "tags": [":>=","fucked_silly","ahegao","torogao","naughty_face"] }
]
}
]
+12
View File
@@ -0,0 +1,12 @@
[
{
"category": "Negative",
"subcategories": [
{ "name": "Quality Issues", "tags": ["worst_quality","low_quality","blurry","distorted","artifacts","noise","pixelated","low_resolution"] },
{ "name": "Anatomy", "tags": ["bad_anatomy","wrong_anatomy","malformed","deformed","twisted","contorted","broken_limbs","extra_limbs","missing_limbs"] },
{ "name": "Faces", "tags": ["bad_face","ugly_face","deformed_face","bad_teeth","bad_eyes","crossed_eyes","asymmetrical_face"] },
{ "name": "Hands", "tags": ["bad_hands","weird_hands","deformed_hands","extra_fingers","missing_fingers","twisted_fingers","fused_fingers"] },
{ "name": "Composition", "tags": ["poorly_composed","cluttered","confusing","amateur","unbalanced","off-center"] }
]
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"category": "Quality",
"subcategories": [
{ "name": "Resolution", "tags": ["8k_uhd","4k","1080p","highres","ultra_highres","masterpiece","best_quality"] },
{ "name": "Detail", "tags": ["hyper-detailed","intricate_details","extremely_detailed","fine_details","sharp_focus","crystal_clear","crisp_details"] },
{ "name": "Rendering", "tags": ["raytracing","path_tracing","ambient_occlusion","photorealistic","realistic_shading","physically_accurate","global_illumination"] },
{ "name": "Award Winning", "tags": ["award-winning","trending_on_artstation","featured_on_pixiv","museum_quality","professionally_done","gallery_quality"] }
]
}
]