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

604 lines
20 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any, Callable
try:
from . import item_axis_policy
except ImportError: # Allows local smoke tests with top-level imports.
import item_axis_policy
WOMAN_LOWER_ACCESS_TERMS = (
"penetrat",
"thrust",
"vaginal",
"anal",
"rear-entry",
"rear entry",
"front-and-back",
"front and back",
"double",
"doggy",
"missionary",
"cowgirl",
"straddles",
"hips aligned",
"penis into",
"penis inside",
"penis entering",
"mouth on her pussy",
"mouth pressed to her pussy",
"pussy licking",
"cunnilingus",
"thighs spread",
"thighs open",
"legs spread",
"legs open",
"cum on pussy",
"cum across her pussy",
"cum dripping from pussy",
"cum dripping from ass",
"cum on belly",
"cum on thighs",
"cum across her ass",
"cum across her lower back",
"toy aligned",
"second penetration point",
)
WOMAN_UPPER_ACCESS_TERMS = (
"boobjob",
"titjob",
"breast sex",
"breasts around",
"breasts tightly",
"hands pressing both breasts",
"breasts together",
"cum on breasts",
"cum across her breasts",
"cum on chest",
)
MAN_LOWER_ACCESS_TERMS = (
"penis",
"glans",
"testicle",
"balls",
"cumshot",
"ejaculat",
"semen",
"boobjob",
"titjob",
"breast sex",
"footjob",
"handjob",
"hand job",
"hand wrapped",
"hand stroking",
"blowjob",
"fellatio",
"penis sucking",
"penis in mouth",
"mouth on penis",
"penis licking",
)
LOWER_BODY_CLOTHING_TERMS = (
"panty",
"panties",
"brief",
"briefs",
"thong",
"bottom",
"bottoms",
"bodysuit",
"teddy",
"dress",
"skirt",
"shorts",
"jeans",
"trousers",
"pants",
"bikini",
"towel",
"sheet",
"blanket",
)
UPPER_BODY_CLOTHING_TERMS = (
"bra",
"cup",
"cups",
"corset",
"bodysuit",
"bustier",
"top",
"camisole",
"shirt",
"blouse",
"bodice",
"dress",
"robe",
"jacket",
"sweater",
"harness",
"chest",
"cleavage",
"panel",
"panels",
)
INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS = [
"wears an open button shirt with jeans lowered below the hips for genital access",
"wears a fitted tee pushed up with trousers lowered below the hips",
"keeps a dark shirt on while pants and underwear are pulled down below the hips",
"wears an open overshirt with jeans pushed down at the thighs",
"wears a hoodie lifted at the waist with sweatpants lowered below the hips",
"wears gym shorts pulled down below the hips with his shirt still on",
"keeps a casual shirt on with belt open and pants lowered below the hips",
"wears a half-open shirt with lower garments pushed down below the hips",
]
INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE = [
"wears an open button shirt with jeans unfastened",
"wears a fitted tee with pants opened at the waist",
"keeps a dark shirt on with trousers loosened",
"wears an open overshirt with jeans partly lowered",
"wears gym shorts loose at the waist with a towel nearby",
"wears a hoodie lifted at the waist with sweatpants loosened",
"wears a casual shirt with belt open and pants partly lowered",
"wears a half-open shirt with dark trousers",
]
def _clean_pair_punctuation(text: Any) -> str:
text = re.sub(r"\s+", " ", str(text or "")).strip()
text = re.sub(r"\s+([,.;:])", r"\1", text)
text = re.sub(r"(?:,\s*){2,}", ", ", text)
text = re.sub(r"\.\s*\.", ".", text)
text = re.sub(r":\s*\.", ".", text)
return text.strip()
def body_exposure_scene_text(scene: Any) -> str:
text = str(scene or "").strip()
if not text:
return ""
replacements = (
(r",?\s*\bscattered (?:clothes|clothing)\b", ""),
(r",?\s*\bfloor clothes\b", ""),
(r"\bclothes scattered\b", "soft floor shadows"),
(r",?\s*\bscattered lingerie\b", ""),
(r",?\s*\blingerie visible nearby\b", ""),
(r"\boutfit racks\b", "mirror shelves"),
(r"\bcostume racks\b", "mirror shelves"),
(r"\bshoe shelves\b", "side shelves"),
(r"\bshoes visible\b", "body placement visible"),
(r"\bbag and shoes visible\b", "nearby floor edge visible"),
(r"\bshoes and bag visible\b", "nearby floor edge visible"),
(r"\bhanging outfits\b", "hanging fabric"),
(r"\bclothing hooks\b", "wall hooks"),
(r"\boutfit-check\b", "creator-shot"),
(r"\boutfit framing\b", "body framing"),
(r"\bfull outfits\b", "full bodies"),
(r"\bcoordinated outfits\b", "coordinated posing"),
)
for pattern, replacement in replacements:
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
text = re.sub(r"\bwith,\s*", "with ", text, flags=re.IGNORECASE)
text = re.sub(r",\s*,", ",", text)
return _clean_pair_punctuation(text)
def softcore_outfit_sentence(label: str, outfit: str) -> str:
outfit = str(outfit or "").strip()
if not outfit:
return ""
lower = outfit.lower()
if lower.startswith(("wears ", "wearing ", "in ")):
return f"{label} {outfit}"
return f"{label} wears {outfit}"
def hardcore_clothing_sentence(label: str, clothing: str) -> str:
clothing = str(clothing or "").strip().rstrip(".")
if not clothing:
return ""
lower = clothing.lower()
if lower.startswith(("fully nude", "nude")):
return f"{label}'s body is fully exposed, bare skin unobstructed"
if lower.startswith("partly nude"):
return f"{label}'s body is partly exposed"
if lower.startswith(("is ", "wears ", "wearing ", "keeps ", "has ", "with ")):
return f"{label} {clothing}"
return f"{label}'s clothing: {clothing}"
def character_hardcore_clothing_entries(
label_map: dict[str, dict[str, Any]],
women_count: int,
men_count: int,
pov_labels: list[str] | None,
rng: Any,
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str],
) -> list[str]:
pov_set = set(pov_labels or [])
labels = [
*[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))],
*[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))],
]
entries: list[str] = []
for label in labels:
if label in pov_set:
continue
clothing = slot_hardcore_clothing(label_map.get(label), rng)
sentence = hardcore_clothing_sentence(label, clothing)
if sentence:
entries.append(sentence)
return entries
def hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]:
axis_values = row.get("item_axis_values")
axis_text = item_axis_policy.context_text(axis_values=axis_values)
role_text = " ".join(
str(part or "")
for part in (
row.get("source_role_graph"),
row.get("role_graph"),
)
).lower()
detail_text = " ".join(
str(part or "")
for part in (
row.get("item"),
row.get("source_composition"),
row.get("composition"),
axis_text,
)
).lower()
full_text = f"{role_text} {detail_text}"
lower_access_text = f"{role_text} {axis_text}"
return {
"woman_lower": any(term in lower_access_text for term in WOMAN_LOWER_ACCESS_TERMS),
"woman_upper": any(term in full_text for term in WOMAN_UPPER_ACCESS_TERMS),
"man_lower": any(term in lower_access_text for term in MAN_LOWER_ACCESS_TERMS),
}
def _outfit_without_lower_body_blockers(outfit: str) -> str:
_removed, remaining = _outfit_split_by_terms(
outfit,
LOWER_BODY_CLOTHING_TERMS,
replacements=(
(r"\blingerie set\b", "lingerie top details"),
(r"\bbrief set\b", "bra set"),
(r"\bbodysuit with\b", "upper bodysuit detail with"),
),
)
return remaining
def _outfit_without_upper_body_blockers(outfit: str) -> str:
_removed, remaining = _outfit_split_by_terms(
outfit,
UPPER_BODY_CLOTHING_TERMS,
replacements=(
(r"\blingerie set\b", "lingerie styling"),
(r"\bbalconette bra and brief set\b", "briefs and garter styling"),
),
)
return remaining
def _split_outfit_fragments(outfit: str, replacements: tuple[tuple[str, str], ...] = ()) -> list[str]:
text = str(outfit or "").strip()
if not text:
return []
for pattern, replacement in replacements:
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
fragments = re.split(r"\s*,\s*|\s+\band\b\s+|\s+\bwith\b\s+|\s+\bunder\b\s+|\s+\bover\b\s+", text)
kept = []
for fragment in fragments:
fragment = fragment.strip(" ,.;")
fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE)
if not fragment:
continue
kept.append(fragment)
deduped = []
seen = set()
for fragment in kept:
key = re.sub(r"\W+", " ", fragment.lower()).strip()
if key and key not in seen:
deduped.append(fragment)
seen.add(key)
return deduped
def _join_fragments(fragments: list[str]) -> str:
return ", ".join(fragment for fragment in fragments if fragment)
def _outfit_split_by_terms(
outfit: str,
terms: tuple[str, ...],
replacements: tuple[tuple[str, str], ...] = (),
) -> tuple[str, str]:
removed: list[str] = []
remaining: list[str] = []
for fragment in _split_outfit_fragments(outfit, replacements):
lower = fragment.lower()
if any(term in lower for term in terms):
removed.append(fragment)
else:
remaining.append(fragment)
return _join_fragments(removed), _join_fragments(remaining)
def _is_plural_clothing_phrase(text: str) -> bool:
lower = text.lower()
if "," in text or " and " in lower:
return True
return any(term in lower for term in ("briefs", "panties", "shorts", "jeans", "trousers", "pants", "stockings"))
def _partially_removed_outfit_state(outfit: str, woman_access: str, implied: bool = False) -> str:
outfit = str(outfit or "").strip()
if not outfit:
return "Woman A's body is partly exposed" if implied else "Woman A's outfit is pushed aside where needed"
if woman_access == "lower":
removed, remaining = _outfit_split_by_terms(
outfit,
LOWER_BODY_CLOTHING_TERMS,
replacements=(
(r"\blingerie set\b", "lingerie top details"),
(r"\bbrief set\b", "bra set"),
(r"\bbodysuit with\b", "upper bodysuit detail with"),
),
)
verb = "are" if _is_plural_clothing_phrase(removed) else "is"
lead = (
f"Woman A's {removed} {verb} pulled aside or removed below the hips"
if removed
else "Woman A's lower body is clear, with the outfit pulled aside below the hips"
)
if remaining:
remain_verb = "remain" if _is_plural_clothing_phrase(remaining) else "remains"
return f"{lead}; {remaining} {remain_verb} visible from the same outfit"
return lead
if woman_access == "upper":
removed, remaining = _outfit_split_by_terms(
outfit,
UPPER_BODY_CLOTHING_TERMS,
replacements=(
(r"\blingerie set\b", "lingerie styling"),
(r"\bbalconette bra and brief set\b", "briefs and garter styling"),
),
)
verb = "are" if _is_plural_clothing_phrase(removed) else "is"
lead = (
f"Woman A's {removed} {verb} pulled open or pushed aside from her breasts and chest"
if removed
else "Woman A's upper body is clear, with the outfit pulled open at the chest"
)
if remaining:
remain_verb = "remain" if _is_plural_clothing_phrase(remaining) else "remains"
return f"{lead}; {remaining} {remain_verb} visible from the same outfit"
return lead
if implied:
return f"Woman A's {outfit} is loosened and partly slipping off, leaving her body partly exposed"
return f"Woman A's {outfit} is pushed aside and partly removed where needed"
def hardcore_clothing_state(
mode: str,
softcore_outfit: str,
continuity_map: dict[str, str],
woman_access: str = "",
) -> str:
mode = mode if mode in continuity_map else "none"
outfit = str(softcore_outfit or "").strip()
if mode == "none" or not outfit:
return ""
base = continuity_map[mode]
if mode == "explicit_nude":
return f"Body exposure: {base}."
if mode == "partially_removed":
return f"Clothing state: {_partially_removed_outfit_state(outfit, woman_access)}."
if mode == "implied_nude":
return f"Clothing state: {_partially_removed_outfit_state(outfit, woman_access, implied=True)}."
return f"Clothing state: {base}; teaser outfit detail: {outfit}."
def default_man_hardcore_clothing_entries(
men_count: int,
pov_labels: list[str] | None,
configured_entries: list[str],
rng: Any,
needs_lower_access: bool,
choose: Callable[[Any, list[str]], str],
) -> list[str]:
pov_set = set(pov_labels or [])
configured_labels = {
match.group(1)
for entry in configured_entries
for match in [re.match(r"^\s*(Man [A-Z])\b", str(entry or ""))]
if match
}
pool = INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS if needs_lower_access else INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE
entries = []
for index in range(max(0, int(men_count))):
label = f"Man {chr(ord('A') + index)}"
if label in pov_set or label in configured_labels:
continue
entries.append(hardcore_clothing_sentence(label, choose(rng, pool)))
return entries
def _pov_clothing_sentence(clothing: str, needs_lower_access: bool) -> str:
clothing = _clean_pair_punctuation(str(clothing or "").strip().rstrip("."))
if not clothing:
return ""
lower = clothing.lower()
if lower.startswith(("fully nude", "nude")):
if needs_lower_access:
return "POV foreground body cue: the viewer's bare hips, thighs, hands, and penis are visible only as first-person body cues"
return "POV foreground body cue: the viewer's bare hands, forearms, or torso edge are visible only as first-person body cues"
clothing = re.sub(r"^(?:wears|wearing|keeps|has|with)\s+", "", clothing, flags=re.IGNORECASE).strip()
if needs_lower_access:
return (
f"POV foreground clothing cue: {clothing}, visible only as the viewer's hands, hips, thighs, or lowered waistband"
)
return (
f"POV foreground clothing cue: {clothing}, visible only as the viewer's hands, forearms, sleeves, or torso edge"
)
def pov_hardcore_clothing_entries(
label_map: dict[str, dict[str, Any]],
pov_labels: list[str] | None,
rng: Any,
needs_lower_access: bool,
choose: Callable[[Any, list[str]], str],
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str] | None = None,
) -> list[str]:
entries: list[str] = []
pool = INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS if needs_lower_access else INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE
for label in pov_labels or []:
slot = label_map.get(label)
clothing = slot_hardcore_clothing(slot, rng) if slot_hardcore_clothing is not None else ""
if not clothing:
clothing = choose(rng, pool)
sentence = _pov_clothing_sentence(clothing, needs_lower_access)
if sentence:
entries.append(sentence)
return entries
@dataclass(frozen=True)
class HardcorePairClothingRoute:
access_flags: dict[str, bool]
woman_access: str
pov_hardcore_clothing: list[str]
default_man_hardcore_clothing: list[str]
hardcore_clothing_state: str
hardcore_clothing_sentence: str
requires_body_exposure_scene: bool
def as_dict(self) -> dict[str, Any]:
return {
"access_flags": dict(self.access_flags),
"woman_access": self.woman_access,
"pov_hardcore_clothing": list(self.pov_hardcore_clothing),
"default_man_hardcore_clothing": list(self.default_man_hardcore_clothing),
"hardcore_clothing_state": self.hardcore_clothing_state,
"hardcore_clothing_sentence": self.hardcore_clothing_sentence,
"requires_body_exposure_scene": self.requires_body_exposure_scene,
}
def resolve_hardcore_pair_clothing_result(
*,
hard_row: dict[str, Any],
mode: str,
softcore_outfit: str,
character_hardcore_clothing_entries: list[str],
men_count: int,
pov_labels: list[str] | None,
rng: Any,
continuity_map: dict[str, str],
choose: Callable[[Any, list[str]], str],
label_map: dict[str, dict[str, Any]] | None = None,
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str] | None = None,
) -> HardcorePairClothingRoute:
access_flags = hardcore_row_access_flags(hard_row)
woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else ""
pov_entries = pov_hardcore_clothing_entries(
label_map or {},
pov_labels,
rng,
access_flags["man_lower"],
choose,
slot_hardcore_clothing,
)
default_man_entries = default_man_hardcore_clothing_entries(
men_count,
pov_labels,
character_hardcore_clothing_entries,
rng,
access_flags["man_lower"],
choose,
)
has_primary_hardcore_clothing = any(entry.startswith("Woman A") for entry in character_hardcore_clothing_entries)
fallback_state = "" if has_primary_hardcore_clothing else hardcore_clothing_state(
mode,
softcore_outfit,
continuity_map,
woman_access=woman_access,
)
hard_clothing_parts = [
part.strip().rstrip(".")
for part in (
fallback_state,
*character_hardcore_clothing_entries,
*pov_entries,
*default_man_entries,
)
if str(part or "").strip()
]
hard_clothing_state = "; ".join(hard_clothing_parts)
scene_cleanup_terms = (
"body is fully exposed",
"bare skin unobstructed",
"body is partly exposed",
"lower body is clear",
"upper body are clear",
"pulled aside",
"removed below the hips",
"pants and underwear are pulled down",
)
hard_clothing_lower = hard_clothing_state.lower()
return HardcorePairClothingRoute(
access_flags=access_flags,
woman_access=woman_access,
pov_hardcore_clothing=pov_entries,
default_man_hardcore_clothing=default_man_entries,
hardcore_clothing_state=hard_clothing_state,
hardcore_clothing_sentence=f"{hard_clothing_state}. " if hard_clothing_state else "",
requires_body_exposure_scene=(
any(access_flags.values())
or any(term in hard_clothing_lower for term in scene_cleanup_terms)
),
)
def resolve_hardcore_pair_clothing(
*,
hard_row: dict[str, Any],
mode: str,
softcore_outfit: str,
character_hardcore_clothing_entries: list[str],
men_count: int,
pov_labels: list[str] | None,
rng: Any,
continuity_map: dict[str, str],
choose: Callable[[Any, list[str]], str],
label_map: dict[str, dict[str, Any]] | None = None,
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str] | None = None,
) -> dict[str, Any]:
return resolve_hardcore_pair_clothing_result(
hard_row=hard_row,
mode=mode,
softcore_outfit=softcore_outfit,
character_hardcore_clothing_entries=character_hardcore_clothing_entries,
men_count=men_count,
pov_labels=pov_labels,
rng=rng,
continuity_map=continuity_map,
choose=choose,
label_map=label_map,
slot_hardcore_clothing=slot_hardcore_clothing,
).as_dict()