Initial release

This commit is contained in:
cyberdelia
2026-04-22 13:27:35 +02:00
parent ca2b24930c
commit 016a8fe25a
5 changed files with 752 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# ComfyUI-PromptFormat
Prompt cleanup for ComfyUI. A port of [sd-webui-prompt-format](https://github.com/Haoming02/sd-webui-prompt-format) with a ComfyUI-native UX: **click-to-format**, not realtime.
## What it does
- **Dedupe tags** — removes duplicates, with optional regex-based aliases (e.g. `1girl: girl, woman, lady`).
- **Remove underscores** — `long_hair` → `long hair`, except for embedding filenames and user-excluded tags (e.g. `score_9`).
- **Fix spacing** — normalizes commas and spaces around `()`, `[]`, `{}`, `|`, `:`.
- **Append commas** — trailing commas on multi-line prompts, so you can break lines without breaking prompts.
- **Strips `<lora:...>` syntax** — ComfyUI uses LoRA loader nodes; inline LoRA tags are dropped.
## Two ways to use it
### 1. The nodes — automatic formatting on run
- **Prompt Format (Cyberdelia)** — string → string. Drop it between any text source and your text encoder.
- **Prompt Format + Encode (Cyberdelia)** — string + CLIP → CONDITIONING. Combines formatting and CLIP encoding in one step.
Both nodes format **automatically when the workflow executes** — no button to click. Use these when you want deterministic, reproducible output baked into your workflow, and when you need full control (exclusions, regex aliases).
### 2. The ✨ Format button — for existing encoder nodes
A `✨ Format` and `↶ Undo` button is injected into the stock text encoder nodes, so you can clean up prompts in place without adding a node to the graph:
- `CLIPTextEncode`
- `CLIPTextEncodeSDXL` / `CLIPTextEncodeSDXLRefiner`
- `CLIPTextEncodeFlux`
- `BNK_CLIPTextEncodeAdvanced` and `smZ CLIPTextEncode`
Right-click for per-node-type toggles (dedupe / remove underscores / append comma), stored in localStorage. Exclusions and aliases aren't exposed on foreign nodes — if you need those, chain a `Prompt Format` node instead.
Formatting is **not realtime** in either case. The button runs on click; the node runs on workflow execute.
## Installation
### ComfyUI Manager
Search for **Prompt Format** and install.
### Manual
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/cyberdeliaAI/ComfyUI-PromptFormat
```
Restart ComfyUI. No extra dependencies.
## Aliases (regex syntax)
Same format as the Forge extension:
```
main_tag: alt1, alt2, regex_pattern
1girl: girl, woman, lady
adult: \d+\s*(y\.?o\.?|[Yy]ear[s]? [Oo]ld)
```
Each alternate on the right is matched as an **anchored regex** against each cleaned tag; if it matches, the tag is replaced by `main_tag` and considered a duplicate from then on.
## Exclusions
Comma-separated list of tags to protect from underscore replacement:
```
score_9, score_8_up, score_7_up, masterpiece_v2
```
Embedding filenames in your `embeddings/` folder are auto-detected and always protected.
## Credits
Ported from [sd-webui-prompt-format](https://github.com/Haoming02/sd-webui-prompt-format) by Haoming02 (MIT-licensed). All formatting logic is derived from the original `prompt_format.js`.
+21
View File
@@ -0,0 +1,21 @@
"""ComfyUI-PromptFormat — Cyberdelia
Port of sd-webui-prompt-format to ComfyUI.
"""
from .prompt_format import PromptFormatNode, PromptFormatEncodeNode
NODE_CLASS_MAPPINGS = {
"CyberdeliaPromptFormat": PromptFormatNode,
"CyberdeliaPromptFormatEncode": PromptFormatEncodeNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CyberdeliaPromptFormat": "Prompt Format (Cyberdelia)",
"CyberdeliaPromptFormatEncode": "Prompt Format + Encode (Cyberdelia)",
}
# Tells ComfyUI to serve everything in ./web as static frontend files
# and auto-load any .js files there as frontend extensions.
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+473
View File
@@ -0,0 +1,473 @@
"""
ComfyUI-PromptFormat
Port of sd-webui-prompt-format (Forge Neo) to ComfyUI.
Cleans up prompts: removes duplicate tags, fixes bracket/comma spacing,
swaps underscores to spaces (with exclusions), and optionally appends
a trailing comma per line.
Unlike the Forge extension, LoRA syntax <lora:...> is stripped,
since ComfyUI handles LoRAs via dedicated loader nodes.
"""
from __future__ import annotations
import re
from pathlib import Path
import folder_paths # type: ignore # provided by ComfyUI at runtime
# --------------------------------------------------------------------------
# Core formatter (Python port of LeFormatter from prompt_format.js)
# --------------------------------------------------------------------------
class PromptFormatter:
"""Stateless formatter. All methods are classmethods; instance-free."""
# Placeholder tokens used during pipeline so regex passes don't mangle
# protected content.
_SHY = "$SHY$"
_CAT = "$CAT$"
_TI_TEMPLATE = "@TEXTUAL{}INVERSION@"
# --- Network (lora/lyco) stripping -----------------------------------
# Matches angle-bracket networks like <lora:name:1.0> and lbw block weights.
_RE_BLOCK_WEIGHT = re.compile(r"(lbw=)?\s*(\d+(\.\d+)?)(\s*,\s*(\d+(\.\d+)?))+")
_RE_NETWORK = re.compile(r"\s*<.+?>\s*")
@classmethod
def _strip_networks(cls, text: str) -> str:
"""Remove <lora:...> / <lyco:...> / block-weight syntax entirely."""
text = cls._RE_BLOCK_WEIGHT.sub("", text)
text = cls._RE_NETWORK.sub(" ", text)
return text
# --- Expression protection (emoticons like "> <" and ":3") -----------
_RE_SHY_IN = re.compile(r"(?:,|\n|^)\s*> <\s*(?:,|\n|$)")
_RE_CAT_IN = re.compile(r"(?:,|\n|^)\s*:3\s*(?:,|\n|$)")
@classmethod
def _protect_expressions(cls, text: str) -> str:
text = cls._RE_SHY_IN.sub(f", {cls._SHY},", text)
text = cls._RE_CAT_IN.sub(f", {cls._CAT},", text)
return text
@classmethod
def _restore_expressions(cls, text: str) -> str:
return text.replace(cls._SHY, "> <").replace(cls._CAT, ":3")
# --- Underscore handling ---------------------------------------------
@classmethod
def _rm_underscore(cls, text: str, protected: list[str]) -> str:
"""Replace underscores with spaces, but not inside protected names
(embedding / textual-inversion filenames or user-excluded tags)."""
if not text.strip():
return ""
# Swap protected names out for placeholders
for i, name in enumerate(protected):
if name and name in text:
text = text.replace(name, cls._TI_TEMPLATE.format(i))
text = text.replace("_", " ")
for i, name in enumerate(protected):
text = text.replace(cls._TI_TEMPLATE.format(i), name)
return text
# --- Deduplication ---------------------------------------------------
_RE_KEYWORD = re.compile(r"^(AND|BREAK)$")
_RE_BRACKETS = re.compile(r"[\[\]\(\)]")
_RE_MULTISPACE = re.compile(r"\s+")
@classmethod
def _dedupe(cls, tags: list[str], aliases: list[tuple[re.Pattern, str]]) -> list[str]:
unique: set[str] = set()
out: list[str] = []
for tag in tags:
cleaned = cls._RE_BRACKETS.sub("", tag)
cleaned = cls._RE_MULTISPACE.sub(" ", cleaned).strip()
# Keep prompt-control keywords (AND, BREAK) as-is, always.
if cls._RE_KEYWORD.match(cleaned):
out.append(tag)
continue
# Pure numbers (e.g. weights) pass through.
try:
float(cleaned)
out.append(tag)
continue
except ValueError:
pass
# Alias lookup: if tag matches a pattern, substitute with main tag
substitute: str | None = None
for pattern, main_tag in aliases:
if pattern.match(cleaned):
substitute = main_tag
break
if substitute is None and cleaned not in unique:
unique.add(cleaned)
out.append(tag)
continue
if substitute is not None and substitute not in unique:
unique.add(substitute)
out.append(tag.replace(cleaned, substitute))
continue
# Duplicate: blank out the content but keep the slot so brackets
# upstream still balance. The later "prune empty chunks" pass
# removes it.
out.append(tag.replace(cleaned, ""))
return out
# --- Main per-line formatter -----------------------------------------
# Comma-after-bracket cleanups
_RE_FIX_COMMAS = [
(re.compile(r",+\s*\)"), "),"),
(re.compile(r",+\s*\]"), "],"),
(re.compile(r",+\s*\}"), "},"),
(re.compile(r"\(\s*,+"), ",("),
(re.compile(r"\[\s*,+"), ",["),
(re.compile(r"\{\s*,+"), ",{"),
]
_RE_FIX_SPACES = [
(re.compile(r"\s+\)"), ")"),
(re.compile(r"\s+\]"), "]"),
(re.compile(r"\s+\}"), "}"),
(re.compile(r"\(\s+"), "("),
(re.compile(r"\[\s+"), "["),
(re.compile(r"\{\s+"), "{"),
]
_RE_PIPE = re.compile(r"\s*\|\s*")
_RE_COLON = re.compile(r"\s*\:\s*")
_RE_EMPTY_BRACKET = re.compile(r"\(\s*\)|\[\s*\]")
_RE_FRANCHISE = re.compile(r"\\\(([^\\\)]+?):([^\\\)]+?)\\\)")
_RE_COLON_CLEAN = re.compile(r",\s*:(\d)")
@classmethod
def format_line(
cls,
line: str,
dedupe: bool,
rm_underscore: bool,
protected: list[str],
aliases: list[tuple[re.Pattern, str]],
) -> str:
# 1) Strip LoRA/network syntax outright (ComfyUI handles those elsewhere)
line = cls._strip_networks(line)
# 2) Underscore replacement (with embedding name protection)
if rm_underscore:
line = cls._rm_underscore(line, protected)
# 3) Protect emoticons
line = cls._protect_expressions(line)
# 4) Normalize commas around brackets
for pattern, repl in cls._RE_FIX_COMMAS:
line = pattern.sub(repl, line)
for pattern, repl in cls._RE_FIX_SPACES:
line = pattern.sub(repl, line)
# 5) Tighten pipe and colon syntax
line = cls._RE_PIPE.sub("|", line)
line = cls._RE_COLON.sub(":", line)
# 6) Split into tags, dedupe, rejoin
tags = [t.strip() for t in line.split(",")]
if dedupe:
tags = cls._dedupe(tags, aliases)
line = ", ".join(tags)
line = cls._RE_MULTISPACE.sub(" ", line)
# 7) Drop empty brackets iteratively
while cls._RE_EMPTY_BRACKET.search(line):
line = cls._RE_EMPTY_BRACKET.sub("", line)
# 8) Franchise escape: \(series: name\) -> \(series: name\) (space after colon)
line = cls._RE_FRANCHISE.sub(r"\\(\1: \2\\)", line)
# 9) Prune empty chunks
line = ", ".join(t.strip() for t in line.split(",") if t.strip())
# 10) Clean up stray empty-before-colon like ",:2"
line = cls._RE_COLON_CLEAN.sub(r":\1", line)
# 11) Restore protected emoticons
line = cls._restore_expressions(line)
return line
@classmethod
def format_pipeline(
cls,
text: str,
dedupe: bool,
rm_underscore: bool,
append_comma: bool,
protected: list[str],
aliases: list[tuple[re.Pattern, str]],
) -> str:
lines = text.split("\n")
formatted = [
cls.format_line(line, dedupe, rm_underscore, protected, aliases)
for line in lines
]
if not append_comma:
return "\n".join(formatted)
joined = ",\n".join(formatted)
joined = re.sub(r"\n,\n", "\n\n", joined)
joined = re.sub(r"\s*,\s*$", "", joined)
return joined
# --------------------------------------------------------------------------
# Helpers for gathering embedding filenames + parsing user aliases
# --------------------------------------------------------------------------
def get_embedding_names() -> list[str]:
"""Scan ComfyUI's embedding folders for filenames to protect during
underscore replacement."""
extensions = (".pt", ".pth", ".ckpt", ".safetensors", ".sft")
names: set[str] = set()
try:
folders = folder_paths.get_folder_paths("embeddings")
except Exception:
folders = []
for folder in folders:
base = Path(folder)
if not base.is_dir():
continue
for ext in extensions:
for file in base.rglob(f"*{ext}"):
names.add(file.stem)
return sorted(names)
def parse_aliases(raw: str) -> list[tuple[re.Pattern, str]]:
"""Parse alias config (same syntax as Forge extension):
main_tag: alt1, alt2, regex_pattern
1girl: girl, woman, lady
adult: \\d+\\s*(y\\.?o\\.?|[Yy]ear[s]? [Oo]ld)
"""
aliases: list[tuple[re.Pattern, str]] = []
if ":" not in raw:
return aliases
for line in raw.splitlines():
if ":" not in line:
continue
tag, words = line.split(":", 1)
main = tag.strip()
if not main:
continue
for word in (w.strip() for w in words.split(",")):
if not word or word == main:
continue
# Strip anchors; we always anchor full-match in the formatter.
cleaned = word.lstrip("^").rstrip("$")
try:
pattern = re.compile(f"^{cleaned}$")
except re.error:
continue
aliases.append((pattern, main))
return aliases
def parse_exclusions(raw: str) -> list[str]:
"""User-provided tags that should NOT have their underscores stripped
(e.g. score_9, score_8_up)."""
return [t.strip() for t in raw.split(",") if t.strip()]
# --------------------------------------------------------------------------
# ComfyUI Node
# --------------------------------------------------------------------------
class PromptFormatNode:
"""Clean up a prompt string.
Inputs: raw prompt text + toggles.
Output: cleaned prompt, ready to feed into a text encoder.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"STRING",
{
"multiline": True,
"dynamicPrompts": True,
"placeholder": "your prompt here...",
"default": "",
},
),
"dedupe": ("BOOLEAN", {"default": True}),
"remove_underscores": ("BOOLEAN", {"default": True}),
"append_comma": ("BOOLEAN", {"default": True}),
},
"optional": {
"exclusions": (
"STRING",
{
"multiline": False,
"default": "",
"placeholder": "score_9, score_8_up, score_7_up",
},
),
"aliases": (
"STRING",
{
"multiline": True,
"default": "",
"placeholder": "1girl: girl, woman, lady\nadult: \\d+\\s*(y\\.?o\\.?|[Yy]ear[s]? [Oo]ld)",
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "format"
CATEGORY = "Cyberdelia/text"
OUTPUT_NODE = False
# Cache embedding list across calls within a session; invalidate cheaply.
_embedding_cache: list[str] | None = None
@classmethod
def _get_protected(cls, exclusions: str) -> list[str]:
if cls._embedding_cache is None:
cls._embedding_cache = get_embedding_names()
extras = parse_exclusions(exclusions)
# Longest names first so "score_8_up" matches before "score".
return sorted(set(cls._embedding_cache + extras), key=len, reverse=True)
def format(
self,
text: str,
dedupe: bool,
remove_underscores: bool,
append_comma: bool,
exclusions: str = "",
aliases: str = "",
):
protected = self._get_protected(exclusions)
alias_list = parse_aliases(aliases)
cleaned = PromptFormatter.format_pipeline(
text,
dedupe=dedupe,
rm_underscore=remove_underscores,
append_comma=append_comma,
protected=protected,
aliases=alias_list,
)
return (cleaned,)
class PromptFormatEncodeNode:
"""Format a prompt AND encode it with a CLIP model in one step.
Equivalent to chaining PromptFormat -> CLIPTextEncode.
Output is CONDITIONING, ready for a sampler.
"""
@classmethod
def INPUT_TYPES(cls):
base = PromptFormatNode.INPUT_TYPES()
base["required"] = {"clip": ("CLIP",), **base["required"]}
return base
RETURN_TYPES = ("CONDITIONING",)
RETURN_NAMES = ("conditioning",)
FUNCTION = "encode"
CATEGORY = "Cyberdelia/text"
def encode(
self,
clip,
text: str,
dedupe: bool,
remove_underscores: bool,
append_comma: bool,
exclusions: str = "",
aliases: str = "",
):
formatter = PromptFormatNode()
(cleaned,) = formatter.format(
text, dedupe, remove_underscores, append_comma, exclusions, aliases
)
tokens = clip.tokenize(cleaned)
output = clip.encode_from_tokens_scheduled(tokens)
return (output,)
# --------------------------------------------------------------------------
# API route for the frontend "Format" button
# --------------------------------------------------------------------------
try:
from aiohttp import web
from server import PromptServer # type: ignore
@PromptServer.instance.routes.post("/cyberdelia/prompt_format")
async def _format_route(request):
"""Frontend calls this when the user clicks the ✨ Format button.
Returns the cleaned prompt so the JS can write it back into the
widget's textarea."""
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid json"}, status=400)
text = data.get("text", "") or ""
dedupe = bool(data.get("dedupe", True))
rm_underscore = bool(data.get("remove_underscores", True))
append_comma = bool(data.get("append_comma", True))
exclusions = data.get("exclusions", "") or ""
aliases = data.get("aliases", "") or ""
protected = PromptFormatNode._get_protected(exclusions)
alias_list = parse_aliases(aliases)
cleaned = PromptFormatter.format_pipeline(
text,
dedupe=dedupe,
rm_underscore=rm_underscore,
append_comma=append_comma,
protected=protected,
aliases=alias_list,
)
return web.json_response({"text": cleaned})
except ImportError:
# If we're imported outside ComfyUI (tests, tooling), skip route setup.
pass
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "comfyui-promptformat"
description = "Prompt cleanup for ComfyUI — port of sd-webui-prompt-format. Dedupe tags, fix bracket/comma spacing, swap underscores. Button-driven, not realtime."
version = "1.0.0"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = []
[project.urls]
Repository = "https://github.com/cyberdeliaAI/ComfyUI-PromptFormat"
[tool.comfy]
PublisherId = "cyberdelia"
DisplayName = "Prompt Format"
Icon = ""
+169
View File
@@ -0,0 +1,169 @@
/**
* ComfyUI-PromptFormat — Frontend extension
*
* Adds a "✨ Format" + "↶ Undo" button to existing prompt nodes that have
* no built-in format step (CLIPTextEncode and its variants). Our own
* PromptFormat nodes are NOT touched — they format automatically when the
* workflow runs, so a button there would be redundant and cause confusion
* (double-apply risk, and inconsistency between widget value and run output).
*
* Clicking the button sends the current prompt to /cyberdelia/prompt_format
* and replaces the widget's value with the cleaned version.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Foreign nodes that get the Format / Undo buttons.
// Add more here if you use other third-party encoders.
const FOREIGN_NODE_ALLOWLIST = new Set([
"CLIPTextEncode",
"CLIPTextEncodeSDXL",
"CLIPTextEncodeSDXLRefiner",
"CLIPTextEncodeFlux",
"BNK_CLIPTextEncodeAdvanced",
"smZ CLIPTextEncode",
]);
// Per-node-type preferences persisted in localStorage. Foreign nodes don't
// have our widget inputs, so toggles live in the right-click menu instead.
const STORAGE_KEY = "cyberdelia_promptformat_prefs";
function loadPrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function savePrefs(prefs) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
/* quota / disabled storage — ignore */
}
}
function getTextWidget(node) {
if (!node.widgets) return null;
// Prefer a widget literally named "text"; fall back to first multiline
// STRING widget so "prompt" / "positive" etc. still work.
const exact = node.widgets.find((w) => w.name === "text");
if (exact) return exact;
return node.widgets.find(
(w) =>
w.type === "customtext" ||
(w.options && w.options.multiline && typeof w.value === "string"),
);
}
async function callFormat(payload) {
const res = await api.fetchApi("/cyberdelia/prompt_format", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Format failed (${res.status})`);
return await res.json();
}
// One undo snapshot per node, keyed by node id.
const undoStore = new Map();
async function formatNode(node) {
const textWidget = getTextWidget(node);
if (!textWidget) return;
const prefs = loadPrefs();
const nodePrefs = prefs[node.comfyClass] || {};
const payload = {
text: textWidget.value ?? "",
dedupe: nodePrefs.dedupe ?? true,
remove_underscores: nodePrefs.remove_underscores ?? true,
append_comma: nodePrefs.append_comma ?? true,
// Foreign nodes don't expose exclusions/aliases — use defaults.
// Users who want custom aliases should chain a PromptFormat node.
exclusions: "",
aliases: "",
};
try {
const { text } = await callFormat(payload);
if (typeof text !== "string") return;
undoStore.set(node.id, textWidget.value);
textWidget.value = text;
if (textWidget.inputEl) textWidget.inputEl.value = text;
node.setDirtyCanvas(true, true);
} catch (err) {
console.error("[PromptFormat]", err);
alert(`Prompt Format failed: ${err.message}`);
}
}
function undoNode(node) {
const previous = undoStore.get(node.id);
if (previous === undefined) return;
const textWidget = getTextWidget(node);
if (!textWidget) return;
textWidget.value = previous;
if (textWidget.inputEl) textWidget.inputEl.value = previous;
undoStore.delete(node.id);
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "Cyberdelia.PromptFormat",
async beforeRegisterNodeDef(nodeType, nodeData) {
const name = nodeData.name;
if (!FOREIGN_NODE_ALLOWLIST.has(name)) return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origOnNodeCreated?.apply(this, arguments);
this.addWidget("button", "✨ Format", "format", () => formatNode(this));
this.addWidget("button", "↶ Undo", "undo", () => undoNode(this));
return r;
};
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
const r = origGetExtraMenuOptions?.apply(this, arguments);
const node = this;
const prefs = loadPrefs();
const nodePrefs = prefs[name] || {};
const toggle = (key, label, fallback = true) => ({
content: `${(nodePrefs[key] ?? fallback) ? "✓ " : " "}${label}`,
callback: () => {
const p = loadPrefs();
p[name] = {
...(p[name] || {}),
[key]: !(p[name]?.[key] ?? fallback),
};
savePrefs(p);
},
});
options.unshift(
{ content: "✨ Format prompt", callback: () => formatNode(node) },
{ content: "↶ Undo format", callback: () => undoNode(node) },
null, // separator
toggle("dedupe", "Dedupe"),
toggle("remove_underscores", "Remove underscores"),
toggle("append_comma", "Append comma"),
null, // separator
);
return r;
};
},
});