Files
ComfyUI-Ethanfel-Prompt-Bui…/sdxl_formatter.py
T

528 lines
19 KiB
Python

from __future__ import annotations
import re
from typing import Any
try:
from . import formatter_input as input_policy
from . import sdxl_presets as sdxl_policy
from .hardcore_action_metadata import normalize_hardcore_action_family
from .prompt_hygiene import sanitize_negative_text, sanitize_tag_prompt
except ImportError: # Allows local smoke tests with `python -c`.
import formatter_input as input_policy
import sdxl_presets as sdxl_policy
from hardcore_action_metadata import normalize_hardcore_action_family
from prompt_hygiene import sanitize_negative_text, sanitize_tag_prompt
TRIGGER_CANDIDATES = (
"sxcpinup_coloredpencil",
"sxcppnl7",
"mythp0rt",
)
SDXL_STYLE_PRESETS = sdxl_policy.SDXL_STYLE_PRESETS
SDXL_QUALITY_PRESETS = sdxl_policy.SDXL_QUALITY_PRESETS
SDXL_FORMATTER_PROFILES = sdxl_policy.SDXL_FORMATTER_PROFILES
SDXL_DEFAULT_NEGATIVE = sdxl_policy.SDXL_DEFAULT_NEGATIVE
SDXL_ACTION_FAMILY_TAGS = sdxl_policy.SDXL_ACTION_FAMILY_TAGS
SDXL_POSITION_FAMILY_TAGS = sdxl_policy.SDXL_POSITION_FAMILY_TAGS
PROMPT_FIELD_LABELS = input_policy.prompt_field_labels()
def sdxl_style_preset_choices() -> list[str]:
return sdxl_policy.sdxl_style_preset_choices()
def sdxl_quality_preset_choices() -> list[str]:
return sdxl_policy.sdxl_quality_preset_choices()
def sdxl_formatter_profile_choices() -> list[str]:
return sdxl_policy.sdxl_formatter_profile_choices()
def _clean(value: Any) -> str:
return input_policy.clean_text(value)
def _maybe_json(text: str) -> dict[str, Any] | None:
return input_policy.maybe_json(text)
def _row_from_inputs(source_text: str, metadata_json: str, input_hint: str) -> tuple[dict[str, Any] | None, str]:
return input_policy.row_from_inputs(source_text, metadata_json, input_hint)
def _strip_trigger(text: str, preserve_trigger: bool) -> str:
return input_policy.strip_trigger_prefix(text, TRIGGER_CANDIDATES, preserve_trigger=preserve_trigger)
def _split_avoid(text: str) -> tuple[str, str]:
return input_policy.split_avoid(text)
def _strip_prompt_field_labels(text: str) -> str:
return input_policy.strip_prompt_field_labels(text, field_labels=PROMPT_FIELD_LABELS)
def _prompt_field(text: str, label: str) -> str:
return input_policy.prompt_field(text, label, field_labels=PROMPT_FIELD_LABELS)
def _row_value(row: dict[str, Any], key: str, labels: tuple[str, ...] = ()) -> str:
return input_policy.row_value(row, key, labels, field_labels=PROMPT_FIELD_LABELS)
def _split_tag_text(text: Any) -> list[str]:
text = _clean(text)
if not text:
return []
text = re.sub(r"\bWoman [A-Z]'s\b", "woman's", text)
text = re.sub(r"\bMan [A-Z]'s\b", "man's", text)
text = re.sub(r"\bWoman [A-Z]\b", "woman", text)
text = re.sub(r"\bMan [A-Z]\b", "man", text)
text = re.sub(
r"\b(?:Clothing state|Visual clothing state|visible remaining styling|teaser outfit detail|softcore visual reference|Sexual scene|Role graph):\s*",
"",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"\b(?:and|with)\b", ",", text, flags=re.IGNORECASE)
parts = re.split(r"\s*[,;]\s*", text)
return [_clean(part).strip(" .") for part in parts if _clean(part).strip(" .")]
def _tag_key(tag: str) -> str:
text = _clean(tag).lower()
text = re.sub(r"^\((.*?):[0-9.]+\)$", r"\1", text)
text = text.strip("() ")
return text
def _add(tags: list[str], seen: set[str], value: Any) -> None:
for tag in _split_tag_text(value):
key = _tag_key(tag)
if key and key not in seen:
tags.append(tag)
seen.add(key)
def _add_one(tags: list[str], seen: set[str], tag: str) -> None:
tag = _clean(tag).strip(" ,")
key = _tag_key(tag)
if tag and key and key not in seen:
tags.append(tag)
seen.add(key)
def _metadata_family_tags(row: dict[str, Any]) -> list[str]:
tags: list[str] = []
action_family = normalize_hardcore_action_family(row.get("action_family"))
tags.extend(SDXL_ACTION_FAMILY_TAGS.get(action_family, ()))
position_family = _clean(row.get("position_family")).lower()
tags.extend(SDXL_POSITION_FAMILY_TAGS.get(position_family, ()))
position_keys = row.get("position_keys")
if isinstance(position_keys, list):
keys = position_keys
else:
keys = [row.get("position_key")]
for key in keys:
key_text = _clean(key)
if key_text:
tags.append(key_text.replace("_", " "))
return tags
def _combine_tags(*parts: Any) -> str:
tags: list[str] = []
seen: set[str] = set()
for part in parts:
_add(tags, seen, part)
return ", ".join(tags)
def _combine_negative(*parts: Any) -> str:
return _combine_tags(*(part for part in parts if _clean(part)))
def _count_tag(women_count: int = 0, men_count: int = 0) -> list[str]:
tags = []
if women_count > 0:
tags.append(f"{women_count}woman" if women_count == 1 else f"{women_count}women")
if men_count > 0:
tags.append(f"{men_count}man" if men_count == 1 else f"{men_count}men")
return tags
def _infer_counts(row: dict[str, Any]) -> tuple[int, int]:
try:
women = int(row.get("women_count") or 0)
men = int(row.get("men_count") or 0)
except (TypeError, ValueError):
women = men = 0
if women or men:
return women, men
subject = _clean(row.get("subject_type") or row.get("primary_subject")).lower()
phrase = _clean(row.get("subject_phrase")).lower()
text = f"{subject} {phrase}"
if "two women" in text:
return 2, 0
if "two men" in text:
return 0, 2
if "woman and" in text or "woman a" in text and "man a" in text:
return 1, 1
if "group" in text:
return 2, 2
if "man" in text and "woman" not in text:
return 0, 1
return 1, 0
def _character_tags_from_descriptor(descriptor: Any) -> list[str]:
text = _clean(descriptor)
text = re.sub(r"\bWoman [A-Z]\s*/\s*primary creator:\s*", "", text)
text = re.sub(r"\b(?:Woman|Man) [A-Z]:\s*", "", text)
text = re.sub(r"\balongside\b", ",", text, flags=re.IGNORECASE)
parts = _split_tag_text(text)
cleaned = []
for part in parts:
part = re.sub(r"\bfigure\b", "build", part, flags=re.IGNORECASE)
part = part.replace("adult adult", "adult")
cleaned.append(part)
return cleaned
def _normal_character_tags(row: dict[str, Any]) -> list[str]:
descriptor = (
_clean(row.get("cast_descriptor_text"))
or _prompt_field(row.get("prompt", ""), "Characters")
or _prompt_field(row.get("prompt", ""), "Cast descriptors")
)
if descriptor:
return _character_tags_from_descriptor(descriptor)
parts = [
_clean(row.get("age") or row.get("age_band")),
_clean(row.get("subject_phrase") or row.get("subject_type") or row.get("primary_subject")),
_clean(row.get("body_phrase") or row.get("body") or row.get("body_type")),
_clean(row.get("skin")),
_clean(row.get("hair")),
_clean(row.get("eyes")),
]
return [part for part in parts if part and part not in ("woman", "man", "single_any")]
def _camera_tags_from_config(config: Any) -> list[str]:
if not isinstance(config, dict):
return []
if _clean(config.get("camera_detail")) == "off" or _clean(config.get("camera_mode")) == "disabled":
return []
custom = _clean(config.get("custom_camera_prompt"))
tags = _split_tag_text(custom)
direction = _clean(config.get("orbit_direction"))
elevation = _clean(config.get("orbit_elevation_label"))
distance = _clean(config.get("orbit_distance_label"))
for value in (direction, elevation, distance):
if value and value != "auto":
tags.extend(_split_tag_text(value))
for key in ("angle", "shot_size", "distance", "lens", "orientation", "subject_focus"):
value = _clean(config.get(key)).replace("_", " ")
if value and value != "auto":
tags.append(value)
return tags
def _camera_tags(row: dict[str, Any], directive: Any = "", config: Any = None) -> list[str]:
tags = _split_tag_text(directive)
tags.extend(_camera_tags_from_config(config if config is not None else row.get("camera_config")))
camera_directive = _clean(row.get("camera_directive"))
if camera_directive:
tags.extend(_split_tag_text(camera_directive))
out = []
for tag in tags:
tag = tag.replace("0-degree front view", "(front facing:1.15)")
tag = tag.replace("front view", "(front facing:1.15)")
tag = tag.replace("right side view", "side view")
tag = tag.replace("left side view", "side view")
out.append(tag)
return out
def _explicit_tags(text: str, nude_weight: float) -> list[str]:
lower = text.lower()
tags: list[str] = []
if any(token in lower for token in ("fully nude", "fully exposed", "naked", "bare skin unobstructed", "explicit_nude")):
tags.append(f"(naked:{nude_weight:.2f})")
if any(token in lower for token in ("nipples", "breasts exposed", "bare breasts", "nipple")):
tags.append("nipples")
if any(token in lower for token in ("pussy", "vulva", "genitals")):
tags.append("pussy")
if any(token in lower for token in ("penis", "cock")):
tags.append("penis")
if "penetration" in lower or "thrust" in lower:
tags.append("penetration")
if "vaginal" in lower:
tags.append("pussy")
if "oral" in lower or "mouth" in lower:
tags.append("oral sex")
if "anal" in lower:
tags.append("anal sex")
if any(token in lower for token in ("semen", "ejaculates", "cum ")):
tags.append("semen")
return tags
def _row_core_tags(row: dict[str, Any], nude_weight: float) -> list[str]:
tags: list[str] = []
seen: set[str] = set()
women, men = _infer_counts(row)
for tag in _count_tag(women, men):
_add_one(tags, seen, tag)
for tag in _normal_character_tags(row):
_add_one(tags, seen, tag)
for tag in _metadata_family_tags(row):
_add_one(tags, seen, tag)
item = _row_value(row, "item", ("Sexual scene", "Sexual pose", "Erotic outfit", "Clothing")) or _clean(row.get("custom_item"))
pose = _row_value(row, "pose", ("Sexual pose", "Pose"))
role_graph = _clean(row.get("source_role_graph") or row.get("role_graph"))
scene = _row_value(row, "scene_text", ("Setting", "Scene")) or _clean(row.get("scene"))
expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression"))
composition = _row_value(row, "composition", ("Composition",))
for value in (
item,
pose,
role_graph,
scene and f"in {scene}",
expression,
composition,
):
_add(tags, seen, value)
for tag in _camera_tags(row):
_add_one(tags, seen, tag)
combined = " ".join(_clean(value) for value in (item, pose, role_graph, row.get("prompt", "")))
for tag in _explicit_tags(combined, nude_weight):
_add_one(tags, seen, tag)
return tags
def _style_prefix(style_preset: str, trigger: str, prepend_trigger: bool, custom_style: str) -> str:
style = custom_style if _clean(custom_style) else SDXL_STYLE_PRESETS.get(
style_preset,
SDXL_STYLE_PRESETS[sdxl_policy.DEFAULT_STYLE_PRESET],
)
trigger = _clean(trigger)
if prepend_trigger and trigger:
return _combine_tags(style, trigger)
return style
def _quality_tail(quality_preset: str, custom_quality: str) -> str:
return _clean(custom_quality) or SDXL_QUALITY_PRESETS.get(
quality_preset,
SDXL_QUALITY_PRESETS[sdxl_policy.DEFAULT_QUALITY_PRESET],
)
def _soft_tags(row: dict[str, Any], root: dict[str, Any], nude_weight: float) -> str:
tags = _row_core_tags(row, nude_weight)
seen = {_tag_key(tag) for tag in tags}
descriptor = _clean(root.get("shared_descriptor"))
if descriptor and not any("woman" in _tag_key(tag) for tag in tags):
for tag in _character_tags_from_descriptor(descriptor):
_add_one(tags, seen, tag)
partner = root.get("softcore_partner_styling")
if isinstance(partner, dict):
_add(tags, seen, "; ".join(_clean(item) for item in partner.get("outfits", []) if _clean(item)))
_add(tags, seen, partner.get("pose"))
_add_one(tags, seen, "sexy")
_add_one(tags, seen, "looking at viewer")
return ", ".join(tags)
def _hard_tags(row: dict[str, Any], root: dict[str, Any], nude_weight: float) -> str:
tags: list[str] = []
seen: set[str] = set()
try:
women = int(root.get("hardcore_women_count") or row.get("women_count") or 1)
men = int(root.get("hardcore_men_count") or row.get("men_count") or 1)
except (TypeError, ValueError):
women, men = 1, 1
for tag in _count_tag(women, men):
_add_one(tags, seen, tag)
descriptors = root.get("shared_cast_descriptors")
if isinstance(descriptors, list):
for descriptor in descriptors:
for tag in _character_tags_from_descriptor(descriptor):
_add_one(tags, seen, tag)
else:
for tag in _normal_character_tags(row):
_add_one(tags, seen, tag)
for tag in _metadata_family_tags(row):
_add_one(tags, seen, tag)
hard_scene = _clean(row.get("scene_text"))
hard_item = _clean(row.get("item"))
hard_role = _clean(row.get("source_role_graph") or row.get("role_graph"))
hard_clothing = _clean(root.get("hardcore_clothing_state"))
expression = _clean(row.get("character_expression_text") or row.get("expression"))
composition = _clean(row.get("composition"))
for value in (
hard_role,
hard_item,
hard_clothing,
hard_scene and f"in {hard_scene}",
expression,
composition,
):
_add(tags, seen, value)
for tag in _camera_tags(row, root.get("hardcore_camera_directive"), root.get("hardcore_camera_config")):
_add_one(tags, seen, tag)
combined = " ".join([hard_role, hard_item, hard_clothing, expression, composition, root.get("hardcore_prompt", "") or ""])
for tag in _explicit_tags(combined, nude_weight):
_add_one(tags, seen, tag)
return ", ".join(tags)
def _assemble_prompt(
body_tags: str,
style_preset: str,
quality_preset: str,
trigger: str,
prepend_trigger: bool,
custom_style: str,
custom_quality: str,
extra_positive: str,
) -> str:
return sanitize_tag_prompt(
_combine_tags(
_style_prefix(style_preset, trigger, prepend_trigger, custom_style),
body_tags,
_quality_tail(quality_preset, custom_quality),
extra_positive,
),
triggers=(trigger,),
)
def _fallback_text_to_sdxl(
source_text: str,
preserve_trigger: bool,
nude_weight: float,
) -> tuple[str, str, str]:
positive, negative = _split_avoid(_strip_trigger(source_text, preserve_trigger))
positive = _strip_prompt_field_labels(positive)
tags = _combine_tags(positive, ", ".join(_explicit_tags(positive, nude_weight)))
return tags, negative, "text(fallback)"
def format_sdxl_prompt(
source_text: str,
metadata_json: str = "",
negative_prompt: str = "",
input_hint: str = "auto",
target: str = "auto",
style_preset: str = "flat_vector_pony",
quality_preset: str = "pony_high",
trigger: str = "mythp0rt",
prepend_trigger: bool = True,
preserve_trigger: bool = False,
nude_weight: float = 1.29,
custom_style: str = "",
custom_quality: str = "",
extra_positive: str = "",
extra_negative: str = "",
formatter_profile: str = "manual_controls",
) -> dict[str, str]:
style_preset, quality_preset = sdxl_policy.apply_formatter_profile(
formatter_profile,
style_preset=style_preset,
quality_preset=quality_preset,
)
target = target if target in ("auto", "single", "softcore", "hardcore") else "auto"
nude_weight = max(0.1, min(3.0, float(nude_weight)))
row, method = _row_from_inputs(source_text, metadata_json, input_hint)
if row and row.get("mode") == "Insta/OF":
soft_row = row.get("softcore_row") if isinstance(row.get("softcore_row"), dict) else {}
hard_row = row.get("hardcore_row") if isinstance(row.get("hardcore_row"), dict) else {}
soft_body = _soft_tags(soft_row, row, nude_weight)
hard_body = _hard_tags(hard_row, row, nude_weight)
soft_prompt = _assemble_prompt(
soft_body,
style_preset,
quality_preset,
trigger,
prepend_trigger,
custom_style,
custom_quality,
extra_positive,
)
hard_prompt = _assemble_prompt(
hard_body,
style_preset,
quality_preset,
trigger,
prepend_trigger,
custom_style,
custom_quality,
extra_positive,
)
selected = hard_prompt if target == "hardcore" else soft_prompt
selected_negative = (
row.get("hardcore_negative_prompt") if target == "hardcore" else row.get("softcore_negative_prompt")
)
return {
"sdxl_prompt": selected,
"negative_prompt": sanitize_negative_text(
_combine_negative(SDXL_DEFAULT_NEGATIVE, selected_negative, negative_prompt, extra_negative)
),
"sdxl_softcore_prompt": soft_prompt,
"sdxl_hardcore_prompt": hard_prompt,
"softcore_negative_prompt": sanitize_negative_text(
_combine_negative(SDXL_DEFAULT_NEGATIVE, row.get("softcore_negative_prompt"), extra_negative)
),
"hardcore_negative_prompt": sanitize_negative_text(
_combine_negative(SDXL_DEFAULT_NEGATIVE, row.get("hardcore_negative_prompt"), extra_negative)
),
"method": f"{method}:sdxl(insta_of_pair)",
}
if row:
body = ", ".join(_row_core_tags(row, nude_weight))
extracted_negative = _clean(row.get("negative_prompt"))
method = f"{method}:sdxl(metadata)"
else:
body, extracted_negative, method = _fallback_text_to_sdxl(source_text, preserve_trigger, nude_weight)
prompt = _assemble_prompt(
body,
style_preset,
quality_preset,
trigger,
prepend_trigger,
custom_style,
custom_quality,
extra_positive,
)
return {
"sdxl_prompt": prompt,
"negative_prompt": sanitize_negative_text(
_combine_negative(SDXL_DEFAULT_NEGATIVE, extracted_negative, negative_prompt, extra_negative)
),
"sdxl_softcore_prompt": "",
"sdxl_hardcore_prompt": "",
"softcore_negative_prompt": "",
"hardcore_negative_prompt": "",
"method": method,
}