diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index b05c2d5..982da1e 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -231,9 +231,10 @@ Already isolated: camera config selection, same-as-softcore mode, camera-detail override, same-room hard scene continuity, camera-aware composition mutation, POV camera suppression, and row/root camera metadata synchronization. -- pair-level hardcore clothing continuity lives in `pair_clothing.py`, - including action-aware body-access flags, conflicting outfit-piece cleanup, - default visible-men clothing, character-clothing override handling, and final +- pair-level clothing policy lives in `pair_clothing.py`, including clothing + sentence formatting, body-exposure scene cleanup, action-aware body-access + flags, conflicting outfit-piece cleanup, default visible-men clothing, + character-clothing override handling, hardcore clothing continuity, and final root clothing-state assembly. - final pair output assembly lives in `pair_output.py`, including soft/hard prompt strings, trigger preservation, negatives, captions, and root metadata diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index dcc1fae..835d1c8 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -82,7 +82,7 @@ Core helper ownership: | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, hardcore cast count policy, and hardcore detail-density directives. | | `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | | `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | -| `pair_clothing.py` | Insta/OF hardcore clothing continuity, action-aware body-access flags, conflicting outfit-piece cleanup, default visible-men clothing, and final root clothing-state assembly. | +| `pair_clothing.py` | Insta/OF clothing sentence formatting, body-exposure scene cleanup, hardcore clothing continuity, action-aware body-access flags, conflicting outfit-piece cleanup, configured/default visible-person clothing, and final root clothing-state assembly. | | `pair_output.py` | Insta/OF final pair prompts, trigger preservation, negative prompts, captions, and root pair metadata assembly. | | `hardcore_role_graphs.py` | Source role graph construction for hardcore configured-cast rows, including POV-aware interaction geometry. | | `hardcore_role_fallback.py` | Solo, same-sex, mixed group fallback, and support-partner role graph wording for configured casts. | diff --git a/pair_clothing.py b/pair_clothing.py index 9f33844..d971874 100644 --- a/pair_clothing.py +++ b/pair_clothing.py @@ -147,6 +147,89 @@ INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE = [ ] +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"\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 = " ".join(str(value) for value in axis_values.values()) if isinstance(axis_values, dict) else "" @@ -272,7 +355,6 @@ def default_man_hardcore_clothing_entries( rng: Any, needs_lower_access: bool, choose: Callable[[Any, list[str]], str], - sentence_builder: Callable[[str, str], str], ) -> list[str]: pov_set = set(pov_labels or []) configured_labels = { @@ -287,7 +369,7 @@ def default_man_hardcore_clothing_entries( label = f"Man {chr(ord('A') + index)}" if label in pov_set or label in configured_labels: continue - entries.append(sentence_builder(label, choose(rng, pool))) + entries.append(hardcore_clothing_sentence(label, choose(rng, pool))) return entries @@ -302,7 +384,6 @@ def resolve_hardcore_pair_clothing( rng: Any, continuity_map: dict[str, str], choose: Callable[[Any, list[str]], str], - sentence_builder: Callable[[str, str], str], ) -> dict[str, Any]: access_flags = hardcore_row_access_flags(hard_row) woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else "" @@ -313,7 +394,6 @@ def resolve_hardcore_pair_clothing( rng, access_flags["man_lower"], choose, - sentence_builder, ) 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( diff --git a/pair_rows.py b/pair_rows.py index 01dd2f9..f740c84 100644 --- a/pair_rows.py +++ b/pair_rows.py @@ -2,6 +2,11 @@ from __future__ import annotations from typing import Any, Callable +try: + from . import pair_clothing +except ImportError: # Allows local smoke tests with top-level imports. + import pair_clothing + BuildPrompt = Callable[..., dict[str, Any]] AxisRng = Callable[[dict[str, int], str, int, int], Any] @@ -45,7 +50,6 @@ def build_insta_pair_rows( softcore_outfit: Callable[[Any, str], str], softcore_pose: Callable[[Any, str], str], softcore_item_prompt_label: Callable[[str], str], - body_exposure_scene_text: Callable[[Any], str], pov_prompt_directive: Callable[[list[str]], str], pov_composition_prompt: Callable[[Any, list[str]], str], ) -> dict[str, Any]: @@ -134,7 +138,7 @@ def build_insta_pair_rows( soft_row["softcore_outfit_policy"] = "character_slot:Woman A" if primary_softcore_outfit else "insta_of_safe_softcore" if softcore_level_key == "explicit_nude": soft_row["source_scene_text"] = soft_row.get("source_scene_text") or soft_row.get("scene_text", "") - soft_row["scene_text"] = body_exposure_scene_text(soft_row.get("scene_text", "")) + soft_row["scene_text"] = pair_clothing.body_exposure_scene_text(soft_row.get("scene_text", "")) soft_row["pov_character_labels"] = ( pov_character_labels if options["softcore_cast"] == "same_as_hardcore" diff --git a/prompt_builder.py b/prompt_builder.py index 23d77ae..7479c10 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -2476,32 +2476,6 @@ def _pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str: return pov_policy.pov_composition_prompt(composition, pov_labels) -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"\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_prompt_punctuation(text) - - def _slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: if not slot: return "" @@ -2524,53 +2498,6 @@ def _slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | No return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "hardcore_clothing", rng) -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 = None, - rng: random.Random | None = None, -) -> 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 _context_from_character_slot( rng: random.Random, slot: dict[str, Any], @@ -4052,7 +3979,7 @@ def _insta_of_partner_styling( label = chr(ord("B") + index) full_label = f"Woman {label}" outfit = _slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS) - sentence = _softcore_outfit_sentence(full_label, outfit) + sentence = pair_clothing.softcore_outfit_sentence(full_label, outfit) if sentence: outfits.append(sentence) for index in range(max(0, men_count)): @@ -4061,7 +3988,7 @@ def _insta_of_partner_styling( if full_label in pov_set: continue outfit = _slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS) - sentence = _softcore_outfit_sentence(full_label, outfit) + sentence = pair_clothing.softcore_outfit_sentence(full_label, outfit) if sentence: outfits.append(sentence) return { @@ -4143,7 +4070,6 @@ def build_insta_of_pair( softcore_outfit=_insta_of_softcore_outfit, softcore_pose=_insta_of_softcore_pose, softcore_item_prompt_label=_insta_of_softcore_item_prompt_label, - body_exposure_scene_text=_body_exposure_scene_text, pov_prompt_directive=_pov_prompt_directive, pov_composition_prompt=_pov_composition_prompt, ) @@ -4219,12 +4145,13 @@ def build_insta_of_pair( soft_cast_presence = cast_context["soft_cast_presence"] soft_cast_styling_sentence = cast_context["soft_cast_styling_sentence"] hard_cast = cast_context["hard_cast"] - character_hardcore_clothing_entries = _character_hardcore_clothing_entries( + character_hardcore_clothing_entries = pair_clothing.character_hardcore_clothing_entries( character_slot_map, hard_women_count, hard_men_count, pov_character_labels, hard_content_rng, + _slot_hardcore_clothing, ) clothing_route = pair_clothing.resolve_hardcore_pair_clothing( hard_row=hard_row, @@ -4236,13 +4163,12 @@ def build_insta_of_pair( rng=hard_content_rng, continuity_map=INSTA_OF_HARDCORE_CLOTHING_CONTINUITY, choose=g.choose, - sentence_builder=_hardcore_clothing_sentence, ) default_man_hardcore_clothing_entries = clothing_route["default_man_hardcore_clothing"] hard_clothing_state = clothing_route["hardcore_clothing_state"] hard_clothing_sentence = clothing_route["hardcore_clothing_sentence"] if clothing_route["requires_body_exposure_scene"]: - hard_scene = _body_exposure_scene_text(hard_scene) + hard_scene = pair_clothing.body_exposure_scene_text(hard_scene) hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "") hard_row["scene_text"] = hard_scene hard_detail_density = options["hardcore_detail_density"] diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index c6f5192..565a691 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -41,6 +41,7 @@ import krea_cast # noqa: E402 import krea_formatter # noqa: E402 import location_config # noqa: E402 import loop_nodes # noqa: E402 +import pair_clothing # noqa: E402 import prompt_builder as pb # noqa: E402 import pov_policy # noqa: E402 import row_normalization # noqa: E402 @@ -1645,6 +1646,41 @@ def smoke_pair_options_policy() -> None: pb.pair_options.hardcore_detail_directive("bad") == "", "invalid hardcore detail density directive should be empty", ) + _expect( + "scattered clothes" not in pair_clothing.body_exposure_scene_text( + "mirror corner, scattered clothes, outfit-check framing" + ), + "Pair clothing body exposure scene cleanup should remove clothing clutter", + ) + _expect( + "creator-shot" in pair_clothing.body_exposure_scene_text("outfit-check framing"), + "Pair clothing body exposure scene cleanup should replace outfit-check wording", + ) + _expect( + pair_clothing.softcore_outfit_sentence("Man A", "wears hoodie and joggers") + == "Man A wears hoodie and joggers", + "Pair clothing softcore outfit sentence formatting changed", + ) + _expect( + pair_clothing.hardcore_clothing_sentence("Woman A", "fully nude") + == "Woman A's body is fully exposed, bare skin unobstructed", + "Pair clothing hardcore fully nude sentence formatting changed", + ) + _expect( + pair_clothing.character_hardcore_clothing_entries( + { + "Woman A": {"hardcore_clothing": "fully nude"}, + "Man A": {"hardcore_clothing": "wears jeans"}, + }, + 1, + 1, + ["Man A"], + random.Random(1), + lambda slot, _rng: str((slot or {}).get("hardcore_clothing") or ""), + ) + == ["Woman A's body is fully exposed, bare skin unobstructed"], + "Pair clothing character entries should skip POV labels", + ) options = json.loads( pb.build_insta_of_options_json( softcore_expression_enabled="false",