diff --git a/categories/expression_composition_pools.json b/categories/expression_composition_pools.json index 9368425..9390aed 100644 --- a/categories/expression_composition_pools.json +++ b/categories/expression_composition_pools.json @@ -235,6 +235,20 @@ "wet lips pressed around a partner", "shameless oral-service stare" ], + "hardcore_outercourse_expressions": [ + "focused downward gaze at the contact point", + "eyes looking up from the contact point", + "mouth open in focused concentration", + "heavy-lidded teasing eye contact", + "controlled hungry expression", + "soft flushed concentration", + "messy lips with steady eye contact", + "breathless focused pleasure face", + "intense close-camera stare", + "wet lips close to the contact point", + "calm concentrated pleasure expression", + "playful shameless eye contact" + ], "hardcore_anal_dp_expressions": [ "controlled braced expression during anal sex", "focused eye contact while hips are held open", diff --git a/categories/sexual_poses.json b/categories/sexual_poses.json index d68a69f..f290062 100644 --- a/categories/sexual_poses.json +++ b/categories/sexual_poses.json @@ -400,12 +400,12 @@ "inherit_compositions": false, "weight": 1.0, "scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"], - "expression_pools": ["hardcore_oral_expressions", "hardcore_messy_expressions"], + "expression_pools": ["hardcore_outercourse_expressions"], "compositions": [ {"text": "tight contact close-up with penis, hands, and face readable", "min_people": 2, "max_people": 3}, {"text": "side-profile non-penetrative contact frame with body geometry clear", "min_people": 2, "max_people": 3}, {"text": "POV-aligned close-up from the man's hips with the visible partner centered", "min_people": 2, "max_people": 2}, - {"text": "low-angle close crop on breasts or feet around the penis", "min_people": 2, "max_people": 3}, + {"text": "low-angle close crop around the penis with the active contact readable", "min_people": 2, "max_people": 3}, {"text": "front-facing kneeling contact composition with eyes, mouth, and hands visible", "min_people": 2, "max_people": 3}, {"text": "mirror-reflected non-penetrative sex composition with contact point readable", "min_people": 2, "max_people": 3}, {"text": "waist-level explicit contact frame with natural skin compression visible", "min_people": 2, "max_people": 3}, @@ -413,7 +413,7 @@ ], "item_templates": [ "{outer_act} in {position}, with {contact_detail}, {hand_detail}, and {visibility}", - "{position} featuring {outer_act}, {body_contact}, {texture_detail}, and {angle}", + "{position} featuring {outer_act}, {body_contact}, {texture_detail}, seen from a {angle} view", "{angle} view of {outer_act}, with {visibility}, {contact_detail}, and {expression_detail}", "explicit non-penetrative sex pose: {outer_act}, {position}, {contact_detail}, and {visibility}", "{outer_act} on {surface}, with {hand_detail}, {body_contact}, and {texture_detail}", diff --git a/krea_formatter.py b/krea_formatter.py index 8807c11..47e4039 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -1404,6 +1404,46 @@ def _dedupe_toy_double_detail(detail: str) -> str: return _clean(detail).strip(" ,;") +def _dedupe_outercourse_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) + context_lower = context.lower() + breast_sex = any(term in context_lower for term in ("boobjob", "titjob", "breast sex", "breast-sex")) + clauses: list[str] = [] + for clause in _detail_clauses(detail): + lower = clause.lower() + if breast_sex: + if lower in ("penis", "breasts", "mouth clearly visible"): + continue + if any( + term in lower + for term in ( + "boobjob", + "titjob", + "breast-sex", + "breast sex", + "seated titjob position", + "kneeling boobjob position", + "tight close-up breast-sex position", + "penis shaft compressed between breasts", + "penis squeezed between both breasts", + "hands pressing the breasts tightly", + "hands pressing breasts firmly together", + "fingers spreading the breasts around the penis shaft", + "soft flesh squeezed around the penis shaft", + "hand wrapped around the penis shaft", + "glans near the mouth", + "glans visible", + "penis, breasts, and mouth clearly visible", + ) + ): + continue + 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(" ,;")] @@ -1727,6 +1767,7 @@ def _hardcore_action_sentence( role_graph = _climax_role_graph(role_graph, hard_item, axis_values) 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)) 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", @@ -1737,6 +1778,10 @@ def _hardcore_action_sentence( if is_climax: anchor = "" detail = _dedupe_climax_detail(detail, role_graph, detail_density) + elif is_outercourse: + anchor = "" + detail = _dedupe_outercourse_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 26b01a5..e97bf05 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1044,19 +1044,34 @@ def _outercourse_axis_values_for_position(values: list[Any], position: str, axis def value_text(value: Any) -> str: return _entry_text(value).lower() - def filtered(terms: tuple[str, ...]) -> list[Any]: - matches = [value for value in values if any(term in value_text(value) for term in terms)] + 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 any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")): by_axis = { - "contact_detail": ("compressed", "glans", "shaft", "skin", "fingers"), + "contact_detail": ("compressed", "glans", "breast", "breasts", "soft tissue", "skin visibly"), "hand_detail": ("breast", "breasts", "fingers"), "texture_detail": ("compression", "soft flesh", "skin", "flesh", "asymmetry"), "visibility": ("breast", "breasts", "glans", "shaft"), "body_contact": ("torso", "body angled", "shoulders", "hips"), } - return filtered(by_axis.get(axis_name, ("breast", "breasts", "shaft"))) + excluded_by_axis = { + "contact_detail": ("hand wrapped", "fingers and palm", "soles", "toes", "balls", "tongue"), + "hand_detail": ("base of the penis", "penis shaft", "balls", "thigh", "ankles", "stroking"), + "texture_detail": ("toes", "soles", "tongue"), + "visibility": ("balls", "soles", "toes", "hand"), + "body_contact": ("head tucked", "face directly", "base of the penis"), + } + return filtered( + by_axis.get(axis_name, ("breast", "breasts", "shaft")), + excluded_by_axis.get(axis_name, ()), + ) if any(term in position_text for term in ("testicle", "balls")): by_axis = { "contact_detail": ("balls", "lips", "tongue", "wet"), @@ -3581,6 +3596,50 @@ def _character_expression_entries( return expressions +def _sanitize_character_expression_text_for_action( + expression_text: str, + role_graph: Any, + item: Any, + axis_values: Any = None, +) -> str: + text = str(expression_text or "").strip() + if not text: + return "" + context = " ".join( + str(part or "").lower() + for part in ( + role_graph, + item, + *((axis_values or {}).values() if isinstance(axis_values, dict) else ()), + ) + ) + woman_active_outercourse = ( + 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 ( + "boobjob", + "titjob", + "breast sex", + "breasts tightly", + "testicle", + "balls-licking", + "balls licking", + "penis-licking", + "penis licking", + "handjob", + "hand job", + "footjob", + ) + ) + ) + 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)] + return "; ".join(clauses) + + def _descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str: detail = _normalize_descriptor_detail(descriptor_detail) if detail != "auto": @@ -5136,8 +5195,8 @@ def _role_graph( "both hands lifting and pressing her breasts tightly around the POV viewer's penis shaft while the glans sits just below her lips." ) return ( - f"{man} sits with legs apart while {woman} kneels between his open thighs with her torso bent forward over his pelvis and shoulders low, " - f"both hands lifting and pressing her breasts tightly around {man}'s penis shaft while the glans sits just below her lips." + f"{woman} kneels between {man}'s open thighs with her torso bent forward over his pelvis and shoulders low while {man} sits with legs apart, " + f"{woman}'s hands lifting and pressing her breasts tightly around {man}'s penis shaft while the glans sits just below her lips." ) if any(term in text for term in ("testicle", "balls-licking", "balls licking", "balls and mouth", "balls held")): if man_is_pov: @@ -5840,6 +5899,13 @@ def _build_custom_row( expression_phase, ) character_expression_text = "; ".join(character_expressions) + character_expression_text = _sanitize_character_expression_text_for_action( + character_expression_text, + source_role_graph, + item, + item_axis_values, + ) + character_expressions = [part.strip() for part in character_expression_text.split(";") if part.strip()] if character_expression_text: expression = character_expression_text source_composition = _choose_text(