Initial release
This commit is contained in:
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user