Files

1237 lines
43 KiB
Python

"""
ComfyUI Prompt Format Converter
Converts prompt text between SD, SD(anima), NAI3, NAI4, and NewBie XML.
The node is intentionally local and dependency-free so it can run inside a
portable ComfyUI install.
Supported conversion rules:
SD -> NAI3: (tag:1.05) -> {tag}, (tag:0.95) -> [tag]
NAI3 -> SD: {tag} -> (tag:1.05), {{{tag}}} -> (tag:1.1576), [tag] -> (tag:0.9524)
SD -> NAI4: (tag:1.2) -> 1.2::tag::
NAI4 -> SD: 1.5::tag:: -> (tag:1.5)
NewBie: XML structure using SD weight syntax inside element text
Artist prefix rules:
SD: plain artist tag, e.g. wlop
SD(anima): @artist tag, e.g. @wlop
NAI3/NAI4: artist: tag, e.g. artist:wlop
NewBie: artists are placed in <style> when generating XML
"""
from __future__ import annotations
import csv
import json
import math
import os
import re
import urllib.request
import xml.etree.ElementTree as ET
from html import escape as xml_escape
from html import unescape as xml_unescape
_NAI3_MULT = 1.05
_MAX_NAI3_LEVEL = 12
_NODE_DIR = os.path.dirname(os.path.abspath(__file__))
_DEFAULT_ARTIST_CSV_URL = "https://huggingface.co/datasets/Laxhar/noob-wiki/resolve/main/danbooru_artist.csv"
_DEFAULT_ARTIST_CSV_PATH = os.path.join(_NODE_DIR, "data", "danbooru_artist.csv")
_DEFAULT_TAG_ORDER_TEMPLATE = (
"[quality/meta/year/safety tags] [1girl/1boy/1other etc] "
"[character] [series] [artist] [general tags]"
)
_DEFAULT_GENERAL_TAG_ORDER = (
"hair color, eye color, hairstyle, topwear, bottomwear, socks/shoes, accessories, "
"expression, action/pose, shared multi-character action, background, lighting, view angle, composition, "
"atmosphere/effects"
)
_RE_SD_WEIGHTED = re.compile(r"(?<!\\)\(((?:[^()\n\\]|\\[^\n])*):(\d+(?:\.\d+)?)\)")
_RE_SD_UNWEIGHTED = re.compile(r"(?<!\\)\(((?:[^()\n\\]|\\[^\n])*)\)(?!\s*:)")
_RE_NAI4 = re.compile(r"(\d+(?:\.\d+)?)::([^\n]+?)::")
_RE_AT_PREFIX = re.compile(r"@(?=[A-Za-z0-9_])")
_RE_ARTIST_PREFIX = re.compile(r"\bartist:")
_RE_COMMENT = re.compile(r"\s+#.*$")
_RE_XML_TAG = re.compile(r"<\s*(character_\d+|general_tags)\b", re.I)
_FORMAT_META = {
"SD": ("SD", ""),
"SD(anima)": ("SD", "@"),
"NAI3": ("NAI3", "artist:"),
"NAI4": ("NAI4", "artist:"),
"NewBie": ("SD", ""),
}
_ALL_FORMATS = ["SD", "SD(anima)", "NAI3", "NAI4", "NewBie"]
# Main prompt converter UI choices. NewBie remains supported internally and by artist formatting,
# while the requested source list focuses on prompt weight formats.
_PROMPT_SOURCE_FORMATS = ["Auto-detect", "SD", "SD(anima)", "NAI3", "NAI4"]
_PROMPT_TARGET_FORMATS = ["SD", "SD(anima)", "NAI3", "NAI4", "NewBie"]
_SRC_FORMATS = ["Auto-detect"] + _ALL_FORMATS
_ARTIST_CSV_CACHE: dict[str, tuple[float, set[str]]] = {}
_NEWBIE_CHARACTER_FIELDS = ["n", "gender", "appearance", "clothing", "expression", "action", "position"]
_NEWBIE_GENERAL_FIELDS = [
"count", "style", "background", "lighting", "atmosphere", "quality", "objects", "other", "caption"
]
_COUNT_TAGS = {
"1girl", "1boy", "2girls", "2boys", "3girls", "3boys", "multiple girls", "multiple boys", "solo",
}
_QUALITY_TAGS = {
"masterpiece", "best quality", "high quality", "high resolution", "highres", "absurdres",
"incredibly absurdres", "official art",
}
_APPEARANCE_HINTS = (
" hair", " eyes", "twintails", "ponytail", "braid", "bangs", "ahoge", "sidelocks", "hime cut",
"short hair", "long hair", "medium hair", "very long hair", "bob cut",
)
_CLOTHING_HINTS = (
"shirt", "skirt", "dress", "coat", "jacket", "hoodie", "sweater", "blouse", "uniform", "kimono",
"pants", "shorts", "thighhighs", "pantyhose", "socks", "boots", "shoes", "hat", "ribbon",
"choker", "necklace", "gloves", "cape", "armor", "bikini", "swimsuit",
)
_EXPRESSION_TAGS = {
"smile", "light smile", "grin", "smirk", "open mouth", "closed mouth", "angry", "annoyed",
"sad", "frown", "serious", "surprised", "embarrassed", "blush", "crying", "tears",
"expressionless", "closed eyes", "one eye closed", "happy", "pout", "scowl",
}
_ACTION_HINTS = (
"standing", "sitting", "kneeling", "lying", "walking", "running", "jumping", "arms", "hand",
"holding", "looking", "pose", "hug", "kiss", "leaning", "reaching", "crossed legs", "legs up",
)
_BACKGROUND_HINTS = (
"background", "outdoors", "indoors", "sky", "cloud", "city", "street", "forest", "beach", "room",
"classroom", "bedroom", "garden", "park", "water", "ocean", "mountain", "night", "sunset",
)
_LIGHTING_HINTS = (
"lighting", "sunlight", "moonlight", "rim lighting", "backlighting", "bloom", "glow", "shadow",
"depth of field", "bokeh", "lens flare",
)
_VIEW_HINTS = (
"from above", "from below", "from behind", "from side", "straight-on", "cowboy shot", "upper body",
"full body", "portrait", "close-up", "wide shot", "pov", "dutch angle", "centered", "symmetry",
)
def _strip_inline_comment(line: str) -> str:
return _RE_COMMENT.sub("", line).strip()
def _split_tags(text: str) -> list[str]:
"""Split comma-separated prompt tags while respecting weight structures."""
parts: list[str] = []
buf: list[str] = []
paren = brace = bracket = 0
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and i + 1 < len(text):
buf.append(ch)
buf.append(text[i + 1])
i += 2
continue
if ch == "(":
paren += 1
elif ch == ")":
paren = max(0, paren - 1)
elif ch == "{":
brace += 1
elif ch == "}":
brace = max(0, brace - 1)
elif ch == "[":
bracket += 1
elif ch == "]":
bracket = max(0, bracket - 1)
if ch == "," and paren == 0 and brace == 0 and bracket == 0:
part = "".join(buf).strip()
if part:
parts.append(part)
buf = []
else:
buf.append(ch)
i += 1
part = "".join(buf).strip()
if part:
parts.append(part)
return parts
def _join_tags(tags: list[str]) -> str:
return ", ".join(t.strip() for t in tags if t and t.strip())
def _strip_weight_for_artist_check(tag: str) -> str:
match = _RE_SD_WEIGHTED.fullmatch(tag.strip())
if match:
return match.group(1).strip()
return tag.strip()
def _clean_artist_name(value: str) -> str:
value = _strip_weight_for_artist_check(value)
value = re.sub(r"^(artist:|@)", "", value.strip())
value = value.replace("\\(", "(").replace("\\)", ")")
return value.strip().strip('"').strip("'")
def _artist_lookup_keys(value: str) -> set[str]:
cleaned = _clean_artist_name(value).lower()
if not cleaned:
return set()
collapsed = re.sub(r"\s+", " ", cleaned).strip()
underscore = collapsed.replace(" ", "_")
no_paren_gap = underscore.replace("_(", "(")
return {
collapsed,
underscore,
no_paren_gap,
collapsed.replace(" ", "_").replace("_(", "("),
}
def _resolve_artist_csv_path(path: str | None) -> str:
path = (path or "").strip()
if not path:
return _DEFAULT_ARTIST_CSV_PATH
if not os.path.isabs(path):
return os.path.join(_NODE_DIR, path)
return path
def _row_artist_candidate(row: list[str], header: list[str] | None) -> str:
if not row:
return ""
if header:
lowered = [item.strip().lower() for item in header]
for name in ("artist", "name", "tag", "tag_name", "tag_string_artist"):
if name in lowered:
idx = lowered.index(name)
if idx < len(row):
return row[idx]
return row[0]
def _load_artist_tags(csv_path: str | None) -> set[str]:
resolved = _resolve_artist_csv_path(csv_path)
if not os.path.exists(resolved):
return set()
mtime = os.path.getmtime(resolved)
cached = _ARTIST_CSV_CACHE.get(resolved)
if cached and cached[0] == mtime:
return cached[1]
tags: set[str] = set()
with open(resolved, "r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle)
rows = iter(reader)
try:
first = next(rows)
except StopIteration:
_ARTIST_CSV_CACHE[resolved] = (mtime, tags)
return tags
header: list[str] | None = None
first_lower = [cell.strip().lower() for cell in first]
if any(cell in {"artist", "name", "tag", "tag_name", "tag_string_artist", "count"} for cell in first_lower):
header = first
else:
for key in _artist_lookup_keys(_row_artist_candidate(first, None)):
tags.add(key)
for row in rows:
candidate = _row_artist_candidate(row, header)
for key in _artist_lookup_keys(candidate):
tags.add(key)
_ARTIST_CSV_CACHE[resolved] = (mtime, tags)
return tags
def _is_exact_artist_tag(tag: str, artist_tags: set[str]) -> bool:
if not artist_tags:
return False
return any(key in artist_tags for key in _artist_lookup_keys(tag))
def _resolve_api_config(provider: str, custom_base_url: str, api_key: str) -> tuple[str, str, dict[str, str]]:
p = provider.strip().lower()
key = api_key.strip()
if p == "openrouter":
base = custom_base_url.strip() or "https://openrouter.ai/api/v1"
if not key:
key = os.getenv("OPENROUTER_API_KEY", "")
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/zisonMyu/ComfyUI-Prompt-Format-Converter",
"X-Title": "ComfyUI Prompt Format Converter",
}
return base.rstrip("/"), key, headers
base = custom_base_url.strip() or "https://api.openai.com/v1"
if not key:
key = os.getenv("OPENAI_API_KEY", "")
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
return base.rstrip("/"), key, headers
def _extract_text_from_chat_response(payload: dict) -> str:
choices = payload.get("choices") or []
if not choices:
return ""
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
chunks: list[str] = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
chunks.append(str(item.get("text", "")))
return "".join(chunks).strip()
return ""
def _call_openai_compatible_chat(
provider: str,
custom_base_url: str,
api_key: str,
model: str,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: int,
) -> tuple[str, str]:
base_url, key, headers = _resolve_api_config(provider, custom_base_url, api_key)
if not key:
env_hint = "OPENROUTER_API_KEY" if provider == "openrouter" else "OPENAI_API_KEY"
return "", f"missing api key (set node input or env {env_hint})"
endpoint = f"{base_url}/chat/completions"
body = {
"model": model.strip(),
"temperature": float(temperature),
"max_tokens": int(max_tokens),
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(endpoint, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=180) as response:
payload = json.loads(response.read().decode("utf-8", errors="ignore"))
text = _extract_text_from_chat_response(payload)
if text:
return text, ""
return "", "empty content returned by model"
except Exception as exc:
return "", f"api request failed: {exc}"
def _build_danbooru_prompt_system_rule(
target_format: str,
tag_order_template: str,
general_tag_order: str,
) -> str:
return f"""
You are a Danbooru prompt structuring engine.
Output language rule: Chinese explanation + English tags.
Strict rules:
1) Always output a single fenced code block.
2) Use English comma-separated tags.
3) Keep category comments with # markers.
4) Escape non-weight literal parentheses as \\( and \\).
5) Keep tag order strictly:
0. Quality tags: masterpiece, best quality, high resolution, etc.
- Only include quality tags if they already exist in the user's original prompt or request.
1. Artist style:
- SD format: @artist
- NAI formats: artist:artist
- If uncertain, omit artist.
2. Count tags: 1girl/1boy/2girls/multiple boys etc.
3. Character name (omit if uncertain)
4. Series/work name (omit if uncertain)
5. Character description order:
- hair color, eye color, hairstyle
- clothing: top -> bottom -> shoes/socks -> accessories
- expression
- action/pose
- shared multi-character action at the last character block
6. Scene & composition:
- background
- lighting
- view angle
- composition
7. Other tags: atmosphere/effects
8. Natural prompt:
- short sentence fragments separated by commas
- each sentence starts with uppercase and ends with "."
- use key Danbooru tags inside the natural prompt
6) Multi-character format:
- use Character1:, Character2:, Character3:...
- quality tags and artist tags only before Character1
- shared scene/lighting/view/composition after all characters, before natural prompt
7) Custom order template that must be respected:
{tag_order_template}
8) General tags detailed order preference:
{general_tag_order}
9) Target output format preference:
{target_format}
- SD means artist prefix @artist
- NAI3/NAI4 means artist:artist
10) If a tag is uncertain as a standard tag, keep it as descriptive English phrase and append [non-standard tag].
""".strip()
def _parse_nai3(text: str, pos: int = 0):
out: list[str] = []
i = pos
n = len(text)
while i < n:
ch = text[i]
if ch in ("}", "]"):
break
if ch == "\\" and i + 1 < n:
out.append(ch)
out.append(text[i + 1])
i += 2
continue
if ch in ("{", "["):
open_c = ch
close_c = "}" if ch == "{" else "]"
level = 0
while i < n and text[i] == open_c:
level += 1
i += 1
inner, i = _parse_nai3(text, i)
closed = 0
while i < n and text[i] == close_c and closed < level:
closed += 1
i += 1
base = _NAI3_MULT if open_c == "{" else (1.0 / _NAI3_MULT)
weight = round(base ** level, 4)
tags = _split_tags(inner)
out.append(_join_tags([f"({tag}:{weight})" for tag in tags]))
continue
out.append(ch)
i += 1
return "".join(out), i
def nai3_to_sd(text: str) -> str:
result, _ = _parse_nai3(text)
return result
def _sd_weight_to_nai3(tag: str, weight: float) -> str:
if abs(weight - 1.0) < 1e-4:
return tag
level = max(1, min(round(abs(math.log(weight)) / math.log(_NAI3_MULT)), _MAX_NAI3_LEVEL))
open_c, close_c = ("{", "}") if weight > 1.0 else ("[", "]")
return open_c * level + tag + close_c * level
def sd_to_nai3(text: str) -> str:
def weighted(match: re.Match) -> str:
return _sd_weight_to_nai3(match.group(1).strip(), float(match.group(2)))
def unweighted(match: re.Match) -> str:
return "{" + match.group(1).strip() + "}"
out = _RE_SD_WEIGHTED.sub(weighted, text)
return _RE_SD_UNWEIGHTED.sub(unweighted, out)
def nai4_to_sd(text: str) -> str:
return _RE_NAI4.sub(lambda m: f"({m.group(2).strip()}:{m.group(1)})", text)
def sd_to_nai4(text: str) -> str:
out = _RE_SD_WEIGHTED.sub(lambda m: f"{m.group(2)}::{m.group(1).strip()}::", text)
return _RE_SD_UNWEIGHTED.sub(lambda m: f"1.1::{m.group(1).strip()}::", out)
_WEIGHT_CONV = {
("NAI3", "SD"): nai3_to_sd,
("SD", "NAI3"): sd_to_nai3,
("NAI4", "SD"): nai4_to_sd,
("SD", "NAI4"): sd_to_nai4,
("NAI3", "NAI4"): lambda text: sd_to_nai4(nai3_to_sd(text)),
("NAI4", "NAI3"): lambda text: sd_to_nai3(nai4_to_sd(text)),
}
def _prefix_from_option(fmt: str) -> str:
return _FORMAT_META.get(fmt, ("SD", ""))[1]
def _prefix_plain_artist_tag(tag: str, new: str, artist_tags: set[str]) -> str:
stripped = tag.strip()
sd_match = _RE_SD_WEIGHTED.fullmatch(stripped)
if sd_match and _is_exact_artist_tag(sd_match.group(1), artist_tags):
return f"({new}{sd_match.group(1).strip()}:{sd_match.group(2)})"
nai4_match = _RE_NAI4.fullmatch(stripped)
if nai4_match and _is_exact_artist_tag(nai4_match.group(2), artist_tags):
return f"{nai4_match.group(1)}::{new}{nai4_match.group(2).strip()}::"
if _is_exact_artist_tag(stripped, artist_tags):
return new + stripped
return tag
def _convert_plain_artist_prefix_exact(text: str, new: str, artist_tags: set[str]) -> str:
if not new or not artist_tags:
return text
return _join_tags([_prefix_plain_artist_tag(tag, new, artist_tags) for tag in _split_tags(text)])
def _convert_artist_prefix(text: str, old: str, new: str, artist_tags: set[str] | None = None) -> str:
if old == new:
return text
if old == "@":
return _RE_AT_PREFIX.sub(new, text)
if old == "artist:":
return _RE_ARTIST_PREFIX.sub(new, text)
if old == "" and new and artist_tags:
return _convert_plain_artist_prefix_exact(text, new, artist_tags)
return text
def detect_format(text: str) -> str:
stripped = text.strip()
if _RE_XML_TAG.search(stripped):
return "NewBie"
if _RE_NAI4.search(stripped):
return "NAI4"
if re.search(r"(?<!\\)[{}\[\]]", stripped):
return "NAI3"
if _RE_AT_PREFIX.search(stripped):
return "SD(anima)"
return "SD"
def _escape_all_literal_parentheses(text: str) -> str:
return re.sub(r"(?<!\\)([()])", r"\\\1", text)
def _escape_sd_literal_parentheses(text: str) -> str:
placeholders: list[str] = []
def keep_weight(match: re.Match) -> str:
placeholders.append(match.group(0))
return f"\x00W{len(placeholders) - 1}\x00"
protected = _RE_SD_WEIGHTED.sub(keep_weight, text)
protected = _escape_all_literal_parentheses(protected)
for i, value in enumerate(placeholders):
protected = protected.replace(f"\x00W{i}\x00", value)
return protected
def escape_literal_parentheses(text: str, target_format: str) -> str:
if target_format in ("SD", "SD(anima)", "NewBie"):
return _escape_sd_literal_parentheses(text)
return _escape_all_literal_parentheses(text)
def _warning_for_nai3(text: str, source_weight: str) -> str:
sd_text = text
if source_weight == "NAI4":
sd_text = nai4_to_sd(text)
elif source_weight == "NAI3":
sd_text = nai3_to_sd(text)
heavy = []
for match in _RE_SD_WEIGHTED.finditer(sd_text):
try:
weight = float(match.group(2))
except ValueError:
continue
if weight > 1.5:
heavy.append(f"{match.group(1).strip()}:{weight:g}")
if heavy:
return "NAI3 warning: weights over 1.5 require deep brace nesting; NAI4 is recommended. Tags: " + ", ".join(heavy[:8])
return ""
def _clean_prompt_lines(text: str) -> list[str]:
lines = []
for raw in text.splitlines():
line = raw.strip()
if not line or line.endswith(":") or line.startswith("#"):
continue
line = _strip_inline_comment(line)
if line:
lines.append(line)
return lines
def _text_to_tags(text: str) -> list[str]:
normalized = ", ".join(_clean_prompt_lines(text))
tags = []
for tag in _split_tags(normalized):
cleaned = tag.strip()
if cleaned and not cleaned.startswith("Character"):
tags.append(cleaned)
return tags
def _matches_hint(lowered: str, hints: tuple[str, ...]) -> bool:
for hint in hints:
h = hint.strip().lower()
if not h:
continue
if lowered == h or lowered.startswith(h + " ") or lowered.endswith(" " + h) or (" " + h + " ") in lowered:
return True
return False
def _classify_tags_for_newbie(tags: list[str], artist_tags: set[str] | None = None) -> dict[str, list[str]]:
artist_tags = artist_tags or set()
data = {field: [] for field in _NEWBIE_GENERAL_FIELDS + _NEWBIE_CHARACTER_FIELDS}
for tag in tags:
bare = tag.strip()
lowered = bare.lower()
if lowered in _COUNT_TAGS:
data["count"].append(bare)
elif lowered in _QUALITY_TAGS:
data["quality"].append(bare)
elif lowered.startswith("@") or lowered.startswith("artist:"):
data["style"].append(re.sub(r"^(artist:|@)", "", bare))
elif _is_exact_artist_tag(bare, artist_tags):
data["style"].append(bare)
elif _matches_hint(lowered, _APPEARANCE_HINTS):
data["appearance"].append(bare)
elif _matches_hint(lowered, _CLOTHING_HINTS):
data["clothing"].append(bare)
elif lowered in _EXPRESSION_TAGS:
data["expression"].append(bare)
elif _matches_hint(lowered, _ACTION_HINTS):
data["action"].append(bare)
elif _matches_hint(lowered, _BACKGROUND_HINTS):
data["background"].append(bare)
elif _matches_hint(lowered, _LIGHTING_HINTS):
data["lighting"].append(bare)
elif _matches_hint(lowered, _VIEW_HINTS):
data["other"].append(bare)
else:
data["other"].append(bare)
return data
def sd_to_newbie(text: str, artist_tags: set[str] | None = None) -> str:
tags = _text_to_tags(text)
data = _classify_tags_for_newbie(tags, artist_tags)
parts = ["<character_1>"]
if data["count"]:
parts.append(f"<gender>{xml_escape(_join_tags(data['count']))}</gender>")
for field in ("appearance", "clothing", "expression", "action", "position"):
value = _join_tags(data[field])
if value:
parts.append(f"<{field}>{xml_escape(value)}</{field}>")
parts.append("</character_1>")
parts.append("<general_tags>")
for field in _NEWBIE_GENERAL_FIELDS:
value = _join_tags(data[field])
if value:
parts.append(f"<{field}>{xml_escape(value)}</{field}>")
parts.append("</general_tags>")
return "\n".join(parts)
def _read_xml_child(parent: ET.Element, name: str) -> str:
child = parent.find(name)
if child is None or child.text is None:
return ""
return xml_unescape(child.text.strip())
def _dedupe_tags(tags: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for tag in tags:
key = tag.strip().lower()
if not key or key in seen:
continue
seen.add(key)
out.append(tag.strip())
return out
def newbie_to_sd(text: str, include_comments: bool = False) -> str:
wrapped = f"<root>{text}</root>"
try:
root = ET.fromstring(wrapped)
except ET.ParseError:
# Be forgiving: if XML is malformed, return the original as normal text.
return text
lines: list[str] = []
flat_tags: list[str] = []
characters = root.findall("character_1") + [
c for c in root if re.match(r"character_\d+", c.tag) and c.tag != "character_1"
]
for idx, character in enumerate(characters, start=1):
char_tags: list[str] = []
for field in _NEWBIE_CHARACTER_FIELDS:
value = _read_xml_child(character, field)
if value:
char_tags.extend(_split_tags(value))
char_tags = _dedupe_tags(char_tags)
if char_tags:
if include_comments:
lines.append(f"Character{idx}:")
lines.append(_join_tags(char_tags) + ", # character")
else:
flat_tags.extend(char_tags)
general = root.find("general_tags")
if general is not None:
sections = [
("quality", "quality"), ("style", "artist/style"), ("count", "count"), ("background", "scene"),
("lighting", "lighting"), ("atmosphere", "atmosphere"), ("objects", "objects"),
("other", "other"), ("caption", "natural prompt"),
]
for field, label in sections:
value = _read_xml_child(general, field)
if not value:
continue
tags = _dedupe_tags(_split_tags(value))
if include_comments:
lines.append(f"{_join_tags(tags)}, # {label}")
else:
flat_tags.extend(tags)
if include_comments:
return "\n".join(lines)
return _join_tags(_dedupe_tags(flat_tags))
def _format_with_comments(
text: str,
target_format: str,
detected: str,
artist_tags: set[str] | None = None,
) -> str:
if target_format == "NewBie":
return text
tags = _text_to_tags(text)
data = _classify_tags_for_newbie(tags, artist_tags)
sections = []
if data["quality"]:
sections.append(("# quality", data["quality"]))
if data["style"]:
style_tags = data["style"]
prefix = _prefix_from_option(target_format)
if prefix:
style_tags = [tag if tag.startswith(prefix) else prefix + re.sub(r"^(artist:|@)", "", tag) for tag in style_tags]
sections.append(("# artist/style", style_tags))
if data["count"]:
sections.append(("# count", data["count"]))
char = data["appearance"] + data["clothing"] + data["expression"] + data["action"] + data["position"]
if char:
sections.append(("# character", char))
scene = data["background"] + data["lighting"]
if scene:
sections.append(("# scene and lighting", scene))
if data["other"]:
sections.append(("# other", data["other"]))
if not sections:
return text
lines = [f"# converted from {detected} to {target_format}"]
for label, values in sections:
lines.append(label)
lines.append(_join_tags(values) + ",")
lines.append("# natural prompt")
lines.append("skip")
return "\n".join(lines)
def convert_prompt(
text: str,
source: str,
target: str,
escape_parentheses_flag: bool = True,
include_comments: bool = False,
use_exact_artist_detection: bool = False,
artist_csv_path: str = "",
):
detected = detect_format(text)
actual = detected if source == "Auto" else source
artist_tags = _load_artist_tags(artist_csv_path) if use_exact_artist_detection else set()
warnings: list[str] = []
# Fast path: if the input already looks like the requested output format,
# avoid regex conversion, prefix rewriting, and parenthesis escaping.
if detected == target and not include_comments:
return text, detected, ""
if target == "NewBie":
sd_text = text
src_weight = _FORMAT_META.get(actual, ("SD", ""))[0]
if actual == "NewBie":
result = text
else:
if src_weight != "SD":
fn = _WEIGHT_CONV.get((src_weight, "SD"))
if fn:
sd_text = fn(sd_text)
# Keep explicit @ / artist: prefixes until classification so artist tags can become <style>.
result = sd_to_newbie(sd_text, artist_tags)
if escape_parentheses_flag:
result = escape_literal_parentheses(result, target)
return result, actual, "\n".join(w for w in warnings if w)
if actual == "NewBie":
result = newbie_to_sd(text, include_comments=include_comments)
src_weight = "SD"
src_prefix = ""
else:
result = text
src_weight, src_prefix = _FORMAT_META.get(actual, ("SD", ""))
tgt_weight, tgt_prefix = _FORMAT_META.get(target, ("SD", ""))
if src_prefix == "" and tgt_prefix and artist_tags:
result = _convert_artist_prefix(result, "", tgt_prefix, artist_tags)
src_prefix = tgt_prefix
if target == "NAI3":
warning = _warning_for_nai3(result, src_weight)
if warning:
warnings.append(warning)
if src_weight != tgt_weight:
fn = _WEIGHT_CONV.get((src_weight, tgt_weight))
if fn:
result = fn(result)
if src_prefix != tgt_prefix:
result = _convert_artist_prefix(result, src_prefix, tgt_prefix, artist_tags)
if escape_parentheses_flag:
result = escape_literal_parentheses(result, target)
if include_comments:
result = _format_with_comments(result, target, actual, artist_tags)
return result, actual, "\n".join(w for w in warnings if w)
def scale_weights(text: str, fmt: str, factor: float, clamp: float) -> str:
fmt = detect_format(text) if fmt == "Auto" else fmt
if fmt == "NewBie":
return sd_to_newbie(scale_weights(newbie_to_sd(text), "SD", factor, clamp))
weight_fmt = _FORMAT_META.get(fmt, ("SD", ""))[0]
if weight_fmt == "NAI3":
return sd_to_nai3(scale_weights(nai3_to_sd(text), "SD", factor, clamp))
if weight_fmt == "NAI4":
def repl_nai4(match: re.Match) -> str:
weight = min(round(float(match.group(1)) * factor, 4), clamp)
return f"{weight}::{match.group(2)}::"
return _RE_NAI4.sub(repl_nai4, text)
def repl_sd(match: re.Match) -> str:
weight = min(round(float(match.group(2)) * factor, 4), clamp)
return f"({match.group(1)}:{weight})"
return _RE_SD_WEIGHTED.sub(repl_sd, text)
class PromptFormatConverter:
"""Convert prompts between SD, SD(anima), NAI3, NAI4, and NewBie XML."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"source_format": (_PROMPT_SOURCE_FORMATS,),
"target_format": (_PROMPT_TARGET_FORMATS,),
"escape_parentheses": ("BOOLEAN", {"default": True}),
"include_comments": ("BOOLEAN", {"default": False}),
"use_exact_artist_detection": ("BOOLEAN", {"default": False}),
"artist_csv_path": ("STRING", {
"default": _DEFAULT_ARTIST_CSV_PATH,
"multiline": False,
"placeholder": "Path to danbooru_artist.csv",
}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("converted_prompt", "detected_source_format", "warnings")
FUNCTION = "run"
CATEGORY = "Prompt Tools"
def run(
self,
prompt,
source_format,
target_format,
escape_parentheses,
include_comments,
use_exact_artist_detection,
artist_csv_path,
):
source = "Auto" if source_format == "Auto-detect" else source_format
result, detected, warnings = convert_prompt(
prompt,
source,
target_format,
escape_parentheses_flag=escape_parentheses,
include_comments=include_comments,
use_exact_artist_detection=use_exact_artist_detection,
artist_csv_path=artist_csv_path,
)
return result, detected, warnings
class DanbooruArtistCSVManager:
"""Check or update the local artist CSV used for exact artist detection."""
_ACTIONS = ["check only", "download/update"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"action": (cls._ACTIONS,),
"csv_url": ("STRING", {
"default": _DEFAULT_ARTIST_CSV_URL,
"multiline": False,
}),
"save_path": ("STRING", {
"default": _DEFAULT_ARTIST_CSV_PATH,
"multiline": False,
"placeholder": "Where danbooru_artist.csv should be stored",
}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("status", "resolved_path", "artist_count")
FUNCTION = "run"
CATEGORY = "Prompt Tools/Artist"
def run(self, action, csv_url, save_path):
resolved = _resolve_artist_csv_path(save_path)
if action == "download/update":
os.makedirs(os.path.dirname(resolved), exist_ok=True)
tmp_path = resolved + ".tmp"
request = urllib.request.Request(
csv_url.strip(),
headers={"User-Agent": "ComfyUI-Prompt-Format-Converter/1.0"},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
with open(tmp_path, "wb") as handle:
handle.write(response.read())
os.replace(tmp_path, resolved)
_ARTIST_CSV_CACHE.pop(resolved, None)
except Exception as exc:
if os.path.exists(tmp_path):
os.remove(tmp_path)
return f"download failed: {exc}", resolved, 0
artist_count = len(_load_artist_tags(resolved))
if artist_count:
return "artist csv ready", resolved, artist_count
if os.path.exists(resolved):
return "artist csv found but no names were loaded", resolved, 0
return "artist csv not found", resolved, 0
class PromptWeightScaler:
"""Multiply explicit prompt weights while preserving the selected format."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"format": (_SRC_FORMATS,),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 5.0, "step": 0.05}),
"clamp_max": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 10.0, "step": 0.1}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("scaled_prompt",)
FUNCTION = "run"
CATEGORY = "Prompt Tools"
def run(self, prompt, format, scale, clamp_max):
fmt = "Auto" if format == "Auto-detect" else format
return (scale_weights(prompt, fmt, scale, clamp_max),)
_ARTIST_STYLE_OPTIONS = ["Auto-detect", "plain SD", "@ - SD(anima)", "artist: - NAI"]
_ARTIST_TARGET_OPTIONS = ["plain SD", "@ - SD(anima)", "artist: - NAI", "NewBie <style>"]
_ARTIST_STYLE_TO_PREFIX = {
"Auto-detect": None,
"plain SD": "",
"@ - SD(anima)": "@",
"artist: - NAI": "artist:",
"NewBie <style>": "",
}
def _detect_artist_prefix_style(text: str) -> tuple[str, str]:
if _RE_ARTIST_PREFIX.search(text):
return "artist:", "artist: - NAI"
if _RE_AT_PREFIX.search(text):
return "@", "@ - SD(anima)"
return "", "plain SD"
def _normalize_artist_names(artist_names: str, escape_parentheses: bool) -> list[str]:
names = [name.strip() for name in re.split(r"[,\n]", artist_names) if name.strip()]
cleaned = [re.sub(r"^(artist:|@)", "", name).strip() for name in names]
if escape_parentheses:
cleaned = [_escape_all_literal_parentheses(name) for name in cleaned]
return [name for name in cleaned if name]
class ArtistNameConverter:
"""Convert a standalone artist-name list between plain SD, @, artist:, and NewBie <style>."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"artist_names": ("STRING", {"multiline": True, "default": "", "placeholder": "wlop\nartist:ask(askzy)\n@torino_aqua"}),
"source_style": (_ARTIST_STYLE_OPTIONS,),
"target_style": (_ARTIST_TARGET_OPTIONS,),
"escape_parentheses": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("converted_artists", "detected_source_style")
FUNCTION = "run"
CATEGORY = "Prompt Tools/Artist"
def run(self, artist_names, source_style, target_style, escape_parentheses):
if source_style == "Auto-detect":
_, detected = _detect_artist_prefix_style(artist_names)
else:
detected = source_style
cleaned = _normalize_artist_names(artist_names, escape_parentheses)
if target_style == "NewBie <style>":
return f"<style>{xml_escape(_join_tags(cleaned))}</style>", detected
prefix = _ARTIST_STYLE_TO_PREFIX.get(target_style, "")
return _join_tags([prefix + name for name in cleaned]), detected
class ArtistTagFormatter:
"""Format artist names for SD, SD(anima), NAI3, NAI4, or NewBie."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"artist_names": ("STRING", {"multiline": True, "default": "", "placeholder": "wlop\nask(askzy)"}),
"target_format": (_PROMPT_TARGET_FORMATS,),
"escape_parentheses": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("formatted_artists",)
FUNCTION = "run"
CATEGORY = "Prompt Tools"
def run(self, artist_names, target_format, escape_parentheses):
cleaned = _normalize_artist_names(artist_names, escape_parentheses)
if target_format == "NewBie":
return (f"<style>{xml_escape(_join_tags(cleaned))}</style>",)
prefix = _prefix_from_option(target_format)
return (_join_tags([prefix + name for name in cleaned]),)
class ArtistPrefixConverter:
"""Convert explicit artist prefixes without changing weight syntax."""
_SRC_OPTS = ["Auto-detect", "@ - SD(anima)", "artist: - NAI", "none - plain SD"]
_TGT_OPTS = ["none - plain SD", "@ - SD(anima)", "artist: - NAI"]
_OPT_TO_PREFIX = {
"Auto-detect": None,
"@ - SD(anima)": "@",
"artist: - NAI": "artist:",
"none - plain SD": "",
}
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"source_prefix": (cls._SRC_OPTS,),
"target_prefix": (cls._TGT_OPTS,),
"use_exact_artist_detection": ("BOOLEAN", {"default": False}),
"artist_csv_path": ("STRING", {
"default": _DEFAULT_ARTIST_CSV_PATH,
"multiline": False,
"placeholder": "Path to danbooru_artist.csv",
}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("converted_prompt", "detected_source_prefix")
FUNCTION = "run"
CATEGORY = "Prompt Tools"
def run(self, prompt, source_prefix, target_prefix, use_exact_artist_detection, artist_csv_path):
src = self._OPT_TO_PREFIX.get(source_prefix)
tgt = self._OPT_TO_PREFIX.get(target_prefix, "")
artist_tags = _load_artist_tags(artist_csv_path) if use_exact_artist_detection else set()
if src is None:
if _RE_ARTIST_PREFIX.search(prompt):
src = "artist:"
detected = "artist: - NAI"
elif _RE_AT_PREFIX.search(prompt):
src = "@"
detected = "@ - SD(anima)"
else:
src = ""
detected = "none - plain SD"
else:
detected = source_prefix
return _convert_artist_prefix(prompt, src, tgt, artist_tags), detected
class AIDanbooruPromptGenerator:
"""Use OpenAI-compatible APIs to generate Danbooru-ordered prompts."""
_PROVIDERS = ["openai", "openrouter"]
_TARGETS = ["SD", "NAI3", "NAI4"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"provider": (cls._PROVIDERS,),
"model": ("STRING", {
"default": "gpt-4.1-mini",
"multiline": False,
"placeholder": "e.g. gpt-4.1-mini / openrouter model id",
}),
"user_requirement": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Describe what you want to generate.",
}),
"source_prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Original prompt text (optional but recommended).",
}),
"target_format": (cls._TARGETS,),
"tag_order_template": ("STRING", {
"multiline": False,
"default": _DEFAULT_TAG_ORDER_TEMPLATE,
}),
"general_tag_order": ("STRING", {
"multiline": False,
"default": _DEFAULT_GENERAL_TAG_ORDER,
}),
"temperature": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.2, "step": 0.05}),
"max_tokens": ("INT", {"default": 1400, "min": 200, "max": 4000, "step": 50}),
"api_key": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Optional. If empty, use OPENAI_API_KEY / OPENROUTER_API_KEY",
}),
"custom_base_url": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Optional. Custom OpenAI-compatible base URL",
}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("generated_prompt", "status")
FUNCTION = "run"
CATEGORY = "Prompt Tools/AI"
def run(
self,
provider,
model,
user_requirement,
source_prompt,
target_format,
tag_order_template,
general_tag_order,
temperature,
max_tokens,
api_key,
custom_base_url,
):
requirement = (user_requirement or "").strip()
original = (source_prompt or "").strip()
if not requirement and not original:
return "", "missing input: provide user_requirement or source_prompt"
if not model.strip():
return "", "missing model name"
system_prompt = _build_danbooru_prompt_system_rule(
target_format=target_format,
tag_order_template=(tag_order_template or _DEFAULT_TAG_ORDER_TEMPLATE).strip(),
general_tag_order=(general_tag_order or _DEFAULT_GENERAL_TAG_ORDER).strip(),
)
user_prompt = (
f"Target format: {target_format}\n"
f"User requirement:\n{requirement or '[empty]'}\n\n"
f"Original prompt:\n{original or '[empty]'}\n\n"
"Generate the final prompt exactly following all rules."
)
text, err = _call_openai_compatible_chat(
provider=provider,
custom_base_url=custom_base_url,
api_key=api_key,
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=temperature,
max_tokens=max_tokens,
)
if err:
return "", err
return text, "ok"
NODE_CLASS_MAPPINGS = {
"PromptFormatConverter": PromptFormatConverter,
"DanbooruArtistCSVManager": DanbooruArtistCSVManager,
"AIDanbooruPromptGenerator": AIDanbooruPromptGenerator,
"PromptWeightScaler": PromptWeightScaler,
"ArtistTagFormatter": ArtistTagFormatter,
"ArtistNameConverter": ArtistNameConverter,
"ArtistPrefixConverter": ArtistPrefixConverter,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptFormatConverter": "Prompt Format Converter (SD/NAI/NewBie)",
"DanbooruArtistCSVManager": "Danbooru Artist CSV Manager",
"AIDanbooruPromptGenerator": "AI Danbooru Prompt Generator (OpenAI/OpenRouter)",
"PromptWeightScaler": "Prompt Weight Scaler",
"ArtistTagFormatter": "Artist Tag Formatter",
"ArtistNameConverter": "Artist Name Converter",
"ArtistPrefixConverter": "Artist Prefix Converter",
}