diff --git a/pair_builder.py b/pair_builder.py index cdc5793..a052b82 100644 --- a/pair_builder.py +++ b/pair_builder.py @@ -247,6 +247,8 @@ def build_insta_of_pair(request: InstaPairBuildRequest, deps: InstaPairBuildDepe rng=hard_content_rng, continuity_map=deps.hardcore_clothing_continuity, choose=deps.choose, + label_map=character_slot_map, + slot_hardcore_clothing=deps.slot_hardcore_clothing, ) if clothing_route.requires_body_exposure_scene: hard_scene = pair_clothing.body_exposure_scene_text(hard_scene) @@ -295,6 +297,7 @@ def build_insta_of_pair(request: InstaPairBuildRequest, deps: InstaPairBuildDepe camera_caption_text=deps.camera_caption_text, cast_descriptors=cast_context["cast_descriptors"], character_hardcore_clothing_entries=character_hardcore_clothing_entries, + pov_hardcore_clothing_entries=clothing_route.pov_hardcore_clothing, default_man_hardcore_clothing_entries=clothing_route.default_man_hardcore_clothing, hard_clothing_state=clothing_route.hardcore_clothing_state, hard_detail_density=hard_detail_density, diff --git a/pair_clothing.py b/pair_clothing.py index f52b364..b81f43b 100644 --- a/pair_clothing.py +++ b/pair_clothing.py @@ -437,10 +437,51 @@ def default_man_hardcore_clothing_entries( 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 @@ -450,6 +491,7 @@ class HardcorePairClothingRoute: 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, @@ -468,9 +510,19 @@ def resolve_hardcore_pair_clothing_result( 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, @@ -491,6 +543,7 @@ def resolve_hardcore_pair_clothing_result( for part in ( fallback_state, *character_hardcore_clothing_entries, + *pov_entries, *default_man_entries, ) if str(part or "").strip() @@ -510,6 +563,7 @@ def resolve_hardcore_pair_clothing_result( 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 "", @@ -531,6 +585,8 @@ def resolve_hardcore_pair_clothing( 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, @@ -542,4 +598,6 @@ def resolve_hardcore_pair_clothing( rng=rng, continuity_map=continuity_map, choose=choose, + label_map=label_map, + slot_hardcore_clothing=slot_hardcore_clothing, ).as_dict() diff --git a/pair_output.py b/pair_output.py index 306b50d..4a753d1 100644 --- a/pair_output.py +++ b/pair_output.py @@ -67,6 +67,7 @@ def assemble_insta_pair_metadata( camera_caption_text: Callable[[dict[str, Any]], str], cast_descriptors: list[str], character_hardcore_clothing_entries: list[str], + pov_hardcore_clothing_entries: list[str], default_man_hardcore_clothing_entries: list[str], hard_clothing_state: str, hard_detail_density: str, @@ -154,6 +155,7 @@ def assemble_insta_pair_metadata( "pov_prompt_directive": pov_directive, "softcore_partner_styling": soft_partner_styling, "character_hardcore_clothing": character_hardcore_clothing_entries, + "pov_hardcore_clothing": pov_hardcore_clothing_entries, "default_man_hardcore_clothing": default_man_hardcore_clothing_entries, "hardcore_clothing_state": hard_clothing_state, "hardcore_detail_density": hard_detail_density, diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index c781025..5e38f8a 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -6311,6 +6311,20 @@ def smoke_pair_route_policy() -> None: implied_lower = implied_route.hardcore_clothing_state.lower() _expect("fabric slipping off" not in implied_lower, "Implied nude clothing should not fall back to generic fabric slipping") _expect("denim shorts" in implied_lower and "fitted bralette" in implied_lower, "Implied nude clothing should mirror softcore outfit pieces") + pov_clothing_route = pair_clothing.resolve_hardcore_pair_clothing_result( + **{ + **clothing_common, + "men_count": 1, + "pov_labels": ["Man A"], + "label_map": {"Man A": {"hardcore_clothing": "open shirt with jeans lowered below the hips"}}, + "slot_hardcore_clothing": lambda slot, _rng: str((slot or {}).get("hardcore_clothing") or ""), + } + ) + pov_clothing_lower = pov_clothing_route.hardcore_clothing_state.lower() + _expect("pov foreground clothing cue" in pov_clothing_lower, "POV man clothing should become a foreground cue") + _expect("jeans lowered below the hips" in pov_clothing_lower, "POV lower-access clothing lost configured lower garment state") + _expect("man a wears" not in pov_clothing_lower, "POV clothing should not describe the POV man as a visible partner") + _expect(not pov_clothing_route.default_man_hardcore_clothing, "POV man should not also receive default visible-man clothing") structured_axis_clothing = pair_clothing.resolve_hardcore_pair_clothing_result( **{ **clothing_common, @@ -6585,9 +6599,15 @@ def smoke_insta_pair_pov() -> None: _expect("Man A" in pov_labels, "pair POV labels should include Man A") hard_row = pair.get("hardcore_row") or {} _expect("Man A" in (hard_row.get("pov_character_labels") or []), "hard row POV labels should include Man A") + pov_clothing = " ".join(pair.get("pov_hardcore_clothing") or []).lower() + _expect("pov foreground clothing cue" in pov_clothing, "pair POV man should get foreground clothing metadata") + _expect("viewer" in pov_clothing, "POV clothing metadata should be phrased through the viewer") + _expect("man a wears" not in (pair.get("hardcore_clothing_state") or "").lower(), "POV clothing state should not describe Man A as visible") krea = krea_formatter.format_krea2_prompt("", metadata_json=_json(pair), target="hardcore") prompt = krea.get("krea_prompt") or "" - _expect("viewer" in prompt.lower(), "POV Krea prompt should mention viewer perspective") + prompt_lower = prompt.lower() + _expect("viewer" in prompt_lower, "POV Krea prompt should mention viewer perspective") + _expect("pov foreground clothing cue" in prompt_lower, "POV Krea prompt lost foreground clothing cue") def smoke_insta_pair_camera_split() -> None: