From 0e7cf60fcbbb7d0be0cdf9ff4d6f6b1719545f0b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 26 Jun 2026 17:02:17 +0200 Subject: [PATCH] Pin POV outercourse position routing --- docs/prompt-pool-routing-map.md | 2 + hardcore_role_graphs.py | 20 +++---- prompt_builder.py | 12 ++++- tools/prompt_smoke.py | 94 ++++++++++++++++++++++++++++++++- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index ecf369c..34ba681 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -730,6 +730,8 @@ pair metadata through the core Python APIs, then verifies: - Krea POV penetration routes keep first-person position anchors, suppress normal camera text, and preserve composition punctuation before the style suffix; +- POV outercourse routes keep constrained boobjob, testicle-sucking, + penis-licking, handjob, and footjob geometry through Krea formatting; - expression-disabled rows do not fall back to generated expression text. - static formatter metadata fixtures keep source-provided action families stable across Krea2 prose, SDXL tags, and natural captions even when raw item diff --git a/hardcore_role_graphs.py b/hardcore_role_graphs.py index 6d45ce1..bf7a80b 100644 --- a/hardcore_role_graphs.py +++ b/hardcore_role_graphs.py @@ -333,16 +333,6 @@ def build_hardcore_role_graph( f"{woman} bends forward between {man}'s open thighs, head low under {man}'s penis with her face directly under the penis, " f"tongue running along the underside from the penis shaft to the glans while one hand steadies the base of the penis." ) - if "handjob" in position_text or "handjob" in text or "hand job" in text or "hand wrapped" in text: - if man_is_pov: - return ( - f"{woman} kneels between the POV viewer's open thighs with her torso leaning forward and face visible behind the penis shaft, " - "one hand wrapped around the POV viewer's penis shaft while the other hand steadies the base of the penis as she strokes toward the glans." - ) - return ( - f"{woman} kneels between {man}'s open thighs with her torso leaning forward and face visible behind the penis shaft, " - f"one hand wrapped around {man}'s penis shaft while the other hand steadies the base of the penis as she strokes toward the glans." - ) if "footjob" in text or "soles" in text or "toes curled" in text or "feet stroking" in text: if man_is_pov: return ( @@ -353,6 +343,16 @@ def build_hardcore_role_graph( f"{man} reclines with hips forward while {woman} faces him with her hips back and both knees bent open, " f"wrapping both soles around {man}'s penis shaft while the contact stays centered." ) + if "handjob" in position_text or "handjob" in text or "hand job" in text or "hand wrapped" in text: + if man_is_pov: + return ( + f"{woman} kneels between the POV viewer's open thighs with her torso leaning forward and face visible behind the penis shaft, " + "one hand wrapped around the POV viewer's penis shaft while the other hand steadies the base of the penis as she strokes toward the glans." + ) + return ( + f"{woman} kneels between {man}'s open thighs with her torso leaning forward and face visible behind the penis shaft, " + f"one hand wrapped around {man}'s penis shaft while the other hand steadies the base of the penis as she strokes toward the glans." + ) if man_is_pov: return ( f"{woman} kneels close to the POV viewer's hips and keeps the POV viewer's penis centered in clear non-penetrative contact, " diff --git a/prompt_builder.py b/prompt_builder.py index 4f5cf6e..40b0714 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1246,13 +1246,21 @@ def _outercourse_axis_values_for_position(values: list[Any], position: str, axis return filtered(by_axis.get(axis_name, ("hand", "penis", "shaft"))) if "footjob" in position_text: by_axis = { - "contact_detail": ("soles", "toes", "shaft"), + "contact_detail": ("soles", "toes"), "hand_detail": ("ankles", "thighs"), "texture_detail": ("toes", "soles", "pressure"), "visibility": ("feet", "soles"), "body_contact": ("legs", "knees", "body angled"), } - return filtered(by_axis.get(axis_name, ("feet", "soles", "toes"))) + excluded_by_axis = { + "contact_detail": ("hand", "finger", "palm", "balls", "tongue", "breast"), + "texture_detail": ("fingers", "tongue", "breast"), + "visibility": ("hand", "balls", "breast"), + } + return filtered( + by_axis.get(axis_name, ("feet", "soles", "toes")), + excluded_by_axis.get(axis_name, ()), + ) return values diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 515044c..7fd1834 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -163,7 +163,7 @@ def _character_cast(*, pov_man: bool = False) -> str: )["character_cast"] -def _action_filter(focus: str) -> str: +def _action_filter(focus: str, hardcore_position_config: str | dict[str, Any] | None = "") -> str: kwargs = { "allow_toys": False, "allow_double": False, @@ -176,7 +176,20 @@ def _action_filter(focus: str) -> str: "allow_anal": focus in ("anal_only", "keep_pool"), "allow_climax": focus in ("climax_only", "keep_pool"), } - return pb.build_hardcore_action_filter_json(focus=focus, **kwargs) + return pb.build_hardcore_action_filter_json( + hardcore_position_config=hardcore_position_config, + focus=focus, + **kwargs, + ) + + +def _position_filter(focus: str, family: str, positions: list[str] | tuple[str, ...] | str) -> str: + position_config = pb.build_hardcore_position_pool_json( + combine_mode="replace", + family=family, + selected_positions=positions, + ) + return _action_filter(focus, position_config) def _coworking_location_config() -> str: @@ -713,6 +726,82 @@ def smoke_krea_pov_penetration_route() -> None: _expect("composition. explicit" in lower, "POV penetration composition sentence should keep punctuation before style suffix") +def smoke_pov_outercourse_position_routes() -> None: + cases = [ + ( + "pov_outercourse_boobjob", + "boobjob", + ("breasts tightly around", "glans sits just below"), + ("press her breasts tightly around", "glans just below", "penis shaft"), + ), + ( + "pov_outercourse_testicle", + "testicle_sucking", + ("mouth and tongue on the pov viewer's balls", "penis points upward"), + ("mouth and tongue licking", "balls", "penis points upward"), + ), + ( + "pov_outercourse_penis_licking", + "penis_licking", + ("head low under the pov viewer's penis", "tongue running along"), + ("tongue runs along", "penis shaft", "glans"), + ), + ( + "pov_outercourse_handjob", + "handjob", + ("one hand wrapped around the pov viewer's penis", "strokes toward the glans"), + ("one hand wraps around", "penis shaft", "strokes toward the glans"), + ), + ( + "pov_outercourse_footjob", + "footjob", + ("both soles wrapped around the pov viewer's penis", "lower foreground"), + ("soles wrap around", "penis shaft", "lower foreground"), + ), + ] + for offset, (name, position_key, role_terms, krea_terms) in enumerate(cases, start=3601): + pair = pb.build_insta_of_pair( + row_number=1, + start_index=1, + seed=offset, + ethnicity="any", + figure="random", + no_plus_women=False, + no_black=False, + trigger=Trigger, + prepend_trigger_to_prompt=True, + options_json=_insta_options( + softcore_camera_mode="from_camera_config", + hardcore_camera_mode="from_camera_config", + camera_detail="compact", + ), + character_cast=_character_cast(pov_man=True), + hardcore_position_config=_position_filter("outercourse_only", "outercourse", [position_key]), + location_config=_coworking_location_config(), + hardcore_camera_config=_orbit_camera( + horizontal_angle=45, + vertical_angle=0, + zoom=7.5, + subject_focus="action", + ), + ) + _expect_pair(pair, name) + hard_row = pair.get("hardcore_row") or {} + _expect(hard_row.get("action_family") == "outercourse", f"{name} action_family should be outercourse") + _expect(hard_row.get("position_family") == "outercourse", f"{name} position_family should be outercourse") + _expect(position_key in (hard_row.get("position_keys") or []), f"{name} lost position key {position_key!r}") + role_graph = _expect_text(f"{name}.source_role_graph", hard_row.get("source_role_graph"), 40).lower() + for term in role_terms: + _expect(term in role_graph, f"{name} role graph missing {term!r}: {role_graph}") + krea = krea_formatter.format_krea2_prompt("", metadata_json=_json(pair), target="hardcore") + prompt = _expect_text(f"{name}.krea_prompt", krea.get("krea_prompt"), 60).lower() + _expect("metadata" in krea.get("method", ""), f"{name}.krea did not use metadata") + _expect("viewer" in prompt and "first-person" in prompt, f"{name} Krea prompt lost POV wording") + _expect("camera:" not in krea.get("krea_prompt", ""), f"{name} Krea prompt emitted normal third-person camera directive") + for term in krea_terms: + _expect(term in prompt, f"{name} Krea prompt missing {term!r}: {prompt}") + + def smoke_no_expression_fallback() -> None: cast = pb.build_character_slot_json( subject_type="woman", @@ -844,6 +933,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("insta_pair_camera_split", smoke_insta_pair_camera_split), ("pov_camera_scene", smoke_pov_camera_scene), ("krea_pov_penetration_route", smoke_krea_pov_penetration_route), + ("pov_outercourse_position_routes", smoke_pov_outercourse_position_routes), ("expression_disabled", smoke_no_expression_fallback), ("formatter_metadata_fixtures", smoke_formatter_metadata_fixtures), ]