diff --git a/krea_formatter.py b/krea_formatter.py index 47e4039..ae6089f 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -829,6 +829,33 @@ def _is_outercourse_text(*parts: Any) -> bool: ) +def _is_oral_text(*parts: Any) -> bool: + text = " ".join(_clean(part).lower() for part in parts if _clean(part)) + return any( + term in text + for term in ( + "oral", + "fellatio", + "blowjob", + "deepthroat", + "penis sucking", + "penis in her mouth", + "penis in mouth", + "takes the man's penis", + "takes his penis", + "mouth at penis level", + "mouth on his penis", + "lips wrapped", + "cunnilingus", + "pussy licking", + "mouth on her pussy", + "mouth pressed to her pussy", + "face-sitting", + "sixty-nine", + ) + ) + + def _is_toy_assisted_double_text(*parts: Any) -> bool: text = " ".join(_clean(part).lower() for part in parts if _clean(part)) if "toy" not in text: @@ -1444,6 +1471,56 @@ def _dedupe_outercourse_detail(detail: str, role_graph: str, hard_item: str = "" return _join_detail_clauses(clauses) +def _dedupe_oral_detail(detail: str, role_graph: str, hard_item: str = "", axis_values: Any = None) -> str: + detail = _clean(detail) + if not detail: + return "" + context = _position_context_text(role_graph, hard_item, "", axis_values) + woman_gives = any( + term in context + for term in ( + "takes the man's penis", + "takes his penis", + "penis in her mouth", + "mouth at penis level", + "mouth on his penis", + "fellatio", + "blowjob", + "deepthroat", + "penis sucking", + ) + ) + clauses: list[str] = [] + for clause in _detail_clauses(detail): + lower = clause.lower() + if any( + term in lower + for term in ( + "kneeling oral position", + "standing oral position", + "edge-of-bed oral position", + "side-lying oral position", + "chair oral position", + "reclining cunnilingus position", + "face-sitting position", + "sixty-nine position", + "fellatio with penis in mouth", + "deepthroat blowjob", + "penis sucking with visible saliva", + "cunnilingus with tongue on pussy", + "oral sex with tongue and fingers", + "oral contact with mouth on the visible genitals", + "bodies stacked close together", + "body angle keeps the penis and face readable", + ) + ): + continue + if woman_gives and lower == "wet shine on genitals": + clause = "saliva dripping on the penis" + clauses.append(clause) + return _join_detail_clauses(clauses) + + def _detail_clauses(detail: str) -> list[str]: return [part.strip(" ,;") for part in re.split(r",\s*(?:and\s+)?", _clean(detail)) if part.strip(" ,;")] @@ -1768,6 +1845,7 @@ def _hardcore_action_sentence( detail = _hardcore_item_detail(hard_item) anchor = _hardcore_pose_anchor(role_graph, hard_item, composition, axis_values) is_outercourse = _is_outercourse_text(role_graph, hard_item, composition, _axis_values_text(axis_values)) + is_oral = _is_oral_text(role_graph, hard_item, composition, _axis_values_text(axis_values)) if _is_toy_assisted_double_text(role_graph, hard_item, composition, _axis_values_text(axis_values)): role_graph = re.sub( r"\s+while a toy adds (?:the|a) second penetration point\b", @@ -1782,6 +1860,10 @@ def _hardcore_action_sentence( anchor = "" detail = _dedupe_outercourse_detail(detail, role_graph, hard_item, axis_values) detail = _limit_detail_for_density(detail, detail_density, False) + elif is_oral and role_graph: + anchor = "" + detail = _dedupe_oral_detail(detail, role_graph, hard_item, axis_values) + detail = _limit_detail_for_density(detail, detail_density, False) else: detail = _dedupe_hardcore_detail(detail, anchor) if anchor else detail if _is_toy_assisted_double_text(role_graph, hard_item, composition, _axis_values_text(axis_values)): diff --git a/prompt_builder.py b/prompt_builder.py index e97bf05..0bd8c81 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -999,6 +999,8 @@ def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]: return filtered(lambda text: "sixty-nine" in text) if "face-sitting" in position_text: return filtered(lambda text: "face-sitting" in text or any(term in text for term in cunnilingus_terms)) + if "kneeling oral" in position_text: + return filtered(lambda text: any(term in text for term in penis_terms)) if "straddled oral" in position_text or "reclining cunnilingus" in position_text: return filtered(lambda text: "sixty-nine" not in text and not any(term in text for term in penis_terms)) if "spread-leg oral" in position_text: @@ -1008,6 +1010,62 @@ def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]: return values +def _oral_axis_values_for_context(values: list[Any], position: str, oral_act: str, axis_name: str) -> list[Any]: + axis_name = str(axis_name or "").lower() + if axis_name not in {"body_contact", "hand_detail", "mouth_detail", "saliva_detail", "climax_hint", "visibility"}: + return values + position_text = str(position or "").lower() + act_text = str(oral_act or "").lower() + woman_gives = any( + term in act_text + for term in ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth") + ) + man_gives = any( + term in act_text + for term in ("cunnilingus", "pussy licking", "tongue on pussy") + ) + if not (woman_gives or man_gives): + return values + + def value_text(value: Any) -> str: + return _entry_text(value).lower() + + def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]: + matches = [ + value + for value in values + if any(term in value_text(value) for term in terms) + and not any(term in value_text(value) for term in excluded_terms) + ] + return matches or values + + if woman_gives: + by_axis = { + "body_contact": ("hips pushed", "fingers tangled", "bodies stacked", "hands on thighs"), + "hand_detail": ("hips", "penis", "head", "hair"), + "mouth_detail": ("lips", "mouth", "deep mouth", "saliva"), + "saliva_detail": ("saliva", "wet lips", "slick wet mouth", "drool", "mouth"), + "climax_hint": ("mouth", "lips", "tongue", "breasts", "belly", "sexual fluids"), + "visibility": ("mouth", "penis", "oral"), + } + excluded = { + "body_contact": ("legs held open", "spread legs", "ass lifted", "chest pressed to thighs"), + "hand_detail": ("spreading thighs", "sheets", "cupping breasts", "pressing into thighs", "holding the ass"), + } + return filtered(by_axis.get(axis_name, ("mouth", "penis")), excluded.get(axis_name, ())) + if man_gives and ("kneeling oral" in position_text or "standing oral" in position_text): + by_axis = { + "body_contact": ("legs held open", "one body kneeling", "chest pressed", "ass lifted", "hands on thighs"), + "hand_detail": ("thigh", "hips", "head", "ass"), + "mouth_detail": ("tongue", "wet lips", "deep mouth", "genitals"), + "saliva_detail": ("saliva", "wet lips", "tongue", "drool"), + "climax_hint": ("sexual fluids", "orgasmic tension"), + "visibility": ("mouth", "pussy", "oral", "genital"), + } + return filtered(by_axis.get(axis_name, ("mouth", "pussy", "tongue")), ("penis", "breasts")) + return values + + def _outercourse_acts_for_position(values: list[Any], position: str) -> list[Any]: position_text = str(position or "").lower() if not position_text: @@ -1136,6 +1194,13 @@ def _compose_item( values = _compatible_entries(axes[name], women_count, men_count) if subcategory_slug == "oral_sex" and name == "oral_act": values = _oral_acts_for_position(values, axis_values.get("position", "")) + elif subcategory_slug == "oral_sex": + values = _oral_axis_values_for_context( + values, + axis_values.get("position", ""), + axis_values.get("oral_act", ""), + name, + ) if subcategory_slug == "outercourse_sex" and name == "outer_act": values = _outercourse_acts_for_position(values, axis_values.get("position", "")) if subcategory_slug == "outercourse_sex": @@ -3634,9 +3699,60 @@ def _sanitize_character_expression_text_for_action( ) ) ) + woman_gives_oral = ( + re.search(r"\bwoman [a-z]\b", context) + and re.search(r"\bman [a-z]\b", context) + and any( + term in context + for term in ( + "takes man", + "penis in her mouth", + "mouth at penis level", + "fellatio", + "blowjob", + "deepthroat", + "penis sucking", + "lips wrapped", + ) + ) + ) + man_gives_oral = ( + re.search(r"\bwoman [a-z]\b", context) + and re.search(r"\bman [a-z]\b", context) + and any( + term in context + for term in ( + "mouth on her pussy", + "mouth on woman", + "mouth pressed to her pussy", + "cunnilingus", + "pussy licking", + "tongue on pussy", + ) + ) + ) + mouth_expression_terms = ("mouth", "oral", "tongue", "lips", "gagging", "saliva") clauses = [clause.strip() for clause in text.split(";") if clause.strip()] if woman_active_outercourse: clauses = [clause for clause in clauses if not re.match(r"^Man [A-Z] has\b", clause)] + if woman_gives_oral: + clauses = [ + clause + for clause in clauses + if not ( + re.match(r"^Man [A-Z] has\b", clause) + and any(term in clause.lower() for term in mouth_expression_terms) + ) + ] + if man_gives_oral: + clauses = [ + clause + for clause in clauses + if not ( + re.match(r"^Woman [A-Z] has\b", clause) + and any(term in clause.lower() for term in mouth_expression_terms) + ) + ] return "; ".join(clauses) @@ -5334,7 +5450,10 @@ def _role_graph( if "kneeling oral" in position_text or ("kneeling oral" in text and not position_text): if man_gives and not woman_gives: return f"{woman} kneels with thighs parted and hips angled forward while {man} kneels in front of her with his mouth on her pussy." - return f"{woman} kneels in front of {man}'s hips with her mouth at penis level while {man} stands close above her." + return ( + f"{woman} kneels in front of {man}'s penis while {man} stands over her; " + f"{woman} takes {man}'s penis in her mouth with saliva dripping on the penis as {man} looks down toward her." + ) if man_gives and not woman_gives: return f"{woman} lies on her back with thighs open while {man} kneels between her legs with his mouth pressed to her pussy." return f"{woman} kneels in front of {man}'s hips and takes his penis in her mouth while {man} keeps his hips aligned with her face."