diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 7b716ed..9368d1f 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -137,7 +137,6 @@ Keep here: - Krea prose style; - hardcore action sentence rewriting; - POV sentence rewriting; -- clothing naturalization; - camera-scene preservation; - fallback text parsing. @@ -145,11 +144,13 @@ Already isolated: - `krea_cast.py` owns cast descriptor parsing, cast prose, label joining, and natural label replacement for formatter routes. +- `krea_clothing.py` owns clothing-state cleanup and action-aware body-access + wording for formatter routes. Improve later: - split semantic blocks into modules: - `krea_actions.py`, `krea_pov.py`, `krea_clothing.py`; + `krea_actions.py`, `krea_pov.py`; - add route-level smoke fixtures for representative metadata rows; - make `_hardcore_action_sentence` dispatch by action family instead of long conditional chains. @@ -330,7 +331,7 @@ Medium-term: ## Recommended Next Passes -1. Split Krea action/POV/clothing helpers into separate modules, using +1. Split Krea action/POV helpers into separate modules, using `krea_cast.py` as the pattern for stable import aliases and smoke coverage. 2. Split `__init__.py` node classes by family after behavior is covered by smoke checks. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 99afe5a..eee7dd7 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -545,10 +545,11 @@ Important POV rule: Key Krea2 ownership: -- Cast descriptor naturalization: `_cast_prose`, `_natural_label_text`. +- Cast descriptor naturalization: `krea_cast.cast_prose`, + `krea_cast.natural_label_text`. - Hardcore action sentence: `_hardcore_action_sentence`. - POV hardcore sentence: `_pov_hardcore_pose_sentence`, `_pov_action_phrase`. -- Clothing state cleanup: `_natural_clothing_state`. +- Clothing state cleanup: `krea_clothing.natural_clothing_state`. - Camera scene preservation: `_camera_scene_phrase`. Krea2 field consumption: @@ -558,7 +559,7 @@ Krea2 field consumption: | Normal single row | `subject_type`, `item`, `pose`, `scene_text`, `expression`, `composition`, `camera_*`, style fields | `_normal_row_to_krea` | | Normal configured cast/hardcore row | `cast_descriptor_text`, `women_count`, `men_count`, `source_role_graph`, `role_graph`, `item`, `item_axis_values`, `source_composition`, `pov_character_labels` | `_normal_row_to_krea`, `_hardcore_action_sentence`, `_pov_action_phrase` | | Insta/OF pair softcore | `shared_descriptor`, `softcore_row`, `softcore_partner_styling`, options, soft camera fields | `_insta_pair_to_krea` | -| Insta/OF pair hardcore | `hardcore_row`, `shared_cast_descriptors`, `hardcore_clothing_state`, `hardcore_detail_density`, hard camera fields, POV labels | `_insta_pair_to_krea`, `_hardcore_action_sentence`, `_pov_action_phrase`, `_natural_clothing_state` | +| Insta/OF pair hardcore | `hardcore_row`, `shared_cast_descriptors`, `hardcore_clothing_state`, `hardcore_detail_density`, hard camera fields, POV labels | `_insta_pair_to_krea`, `_hardcore_action_sentence`, `_pov_action_phrase`, `krea_clothing.natural_clothing_state` | | Plain text fallback | `source_text` only | `_fallback_text_to_krea` | If metadata is connected and `method` says `text(fallback)`, the formatter did @@ -700,7 +701,7 @@ pair metadata through the core Python APIs, then verifies: | --- | --- | | Wrong main category/subcategory frequency | Category node config, `load_category_library`, category JSON weights. | | Wrong outfit/clothing item | Relevant category JSON, `INSTA_OF_SOFTCORE_OUTFITS`, `SxCP Character Clothing`. | -| Nude/clothing state confusing Krea2 | `build_insta_of_pair` clothing state helpers, then `_natural_clothing_state`. | +| Nude/clothing state confusing Krea2 | `build_insta_of_pair` clothing state helpers, then `krea_clothing.natural_clothing_state`. | | Wrong location | `categories/location_pools.json`, category `scene_pool`, `_scene_pool`. | | Location good but camera/location layout wrong | `_camera_scene_directive_for_context`, coworking adapter functions. | | Repeated desk/anchor in POV foreground | Coworking direction/distance/elevation helpers. | @@ -763,7 +764,7 @@ Use these traces to narrow a problem in one pass. 1. Check pair root `hardcore_clothing_state`. 2. Check hard row `item` and `source_role_graph` for access flags. 3. Character slot `hardcore_clothing` overrides pair fallback clothing. -4. For Krea wording, inspect `_natural_clothing_state`. +4. For Krea wording, inspect `krea_clothing.natural_clothing_state`. 5. For generation wording, inspect `_insta_of_hardcore_clothing_state`, `_hardcore_row_access_flags`, and `character_hardcore_clothing_values`. diff --git a/krea_clothing.py b/krea_clothing.py new file mode 100644 index 0000000..d1ed4fb --- /dev/null +++ b/krea_clothing.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import re +from typing import Any + +try: + from .krea_cast import natural_label_text +except ImportError: # Allows local smoke tests with `python -c`. + from krea_cast import natural_label_text + + +def _clean(value: Any) -> str: + text = "" if value is None else str(value) + text = text.replace("\n", " ") + text = re.sub(r"\s+", " ", text).strip() + text = re.sub(r"\s+([,.;:])", r"\1", text) + return text + + +def clothing_access_phrase(action_text: Any) -> str: + text = _clean(action_text).lower() + if any(term in text for term in ("cumshot", "ejaculat", "semen", "cum on", "cum across", "post-orgasm", "aftermath")): + return "leaving the body exposed for visible semen and aftermath" + if any(term in text for term in ("boobjob", "titjob", "breast sex", "handjob", "hand job", "footjob", "testicle", "balls", "penis licking", "non-penetrative")): + return "leaving the contact point unobstructed" + if any(term in text for term in ("oral", "blowjob", "fellatio", "mouth", "tongue")): + return "leaving the oral contact unobstructed" + if any(term in text for term in ("penetrat", "thrust", "penis entering", "vaginal", "anal")): + return "leaving the penetration point unobstructed" + return "leaving skin and body contact readable" + + +def natural_clothing_state(text: Any, action_text: Any = "") -> str: + text = _clean(text) + if not text: + return "" + text = re.sub(r"^Clothing state:\s*", "", text, flags=re.IGNORECASE) + if re.search(r";\s*(?=(?:Woman|Man) [A-Z]\b)", text): + parts = [ + natural_clothing_state(part, action_text).rstrip(".") + for part in re.split(r";\s*(?=(?:Woman|Man) [A-Z]\b)", text) + if _clean(part) + ] + return ". ".join(part for part in parts if part) + body_exposure = re.match(r"^Body exposure:\s*(.*?)\.?$", text, flags=re.IGNORECASE) + if body_exposure: + return _clean(body_exposure.group(1)).rstrip(".") + if re.search(r"\bfully nude\b|\bbody is fully exposed\b|\bno clothing covering\b", text, flags=re.IGNORECASE): + owner = "the woman" + owner_match = re.match(r"^\s*((?:Woman|Man) [A-Z])\b", text) + if owner_match: + owner = natural_label_text(owner_match.group(1), ["Woman A", "Man A"]) or owner + return f"{owner.capitalize()}'s body is fully exposed, bare skin unobstructed" + match = re.match( + r"^(.*?)\b(?:softcore|teaser) outfit is (.*?)(?: for the (?:hardcore|sex) scene)?;\s*(?:softcore visual reference|teaser outfit detail):\s*(.*?)\.?$", + text, + flags=re.IGNORECASE, + ) + if match: + owner = natural_label_text(match.group(1).strip(" 's"), ["Woman A", "Man A"]).strip() or "the woman" + state = _clean(match.group(2)).lower() + outfit = _clean(match.group(3)).rstrip(".") + if "fully nude" in state or "fully exposed" in state or "no clothing covering" in state: + return f"{owner.capitalize()}'s body is fully exposed, bare skin unobstructed" + if "nude-adjacent" in state: + return f"{owner.capitalize()}'s body is partly exposed" + if "partially removed" in state or "pushed aside" in state: + return f"{owner.capitalize()}'s {outfit} is pushed aside or partly removed where needed, {clothing_access_phrase(action_text)}" + if "keeps" in state: + return f"{owner.capitalize()} keeps the {outfit} on while {clothing_access_phrase(action_text)}" + text = re.sub(r";\s*(?:softcore visual reference|teaser outfit detail):\s*", ". Visual clothing state: ", text, flags=re.IGNORECASE) + text = text.replace("softcore outfit", "outfit") + text = text.replace("teaser outfit", "outfit") + text = text.replace("hardcore scene", "sex scene") + return text diff --git a/krea_formatter.py b/krea_formatter.py index d050d75..30566b0 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -16,6 +16,7 @@ try: natural_label_text as _natural_label_text, prompt_cast_descriptors as _prompt_cast_descriptors, ) + from .krea_clothing import natural_clothing_state as _natural_clothing_state from .prompt_hygiene import sanitize_negative_text, sanitize_prose_text except ImportError: # Allows local smoke tests with `python -c`. from hardcore_text_cleanup import ( @@ -29,6 +30,7 @@ except ImportError: # Allows local smoke tests with `python -c`. natural_label_text as _natural_label_text, prompt_cast_descriptors as _prompt_cast_descriptors, ) + from krea_clothing import natural_clothing_state as _natural_clothing_state from prompt_hygiene import sanitize_negative_text, sanitize_prose_text @@ -593,64 +595,6 @@ def _sanitize_scene_text_for_cast(text: Any, labels: list[str]) -> str: return text -def _clothing_access_phrase(action_text: Any) -> str: - text = _clean(action_text).lower() - if any(term in text for term in ("cumshot", "ejaculat", "semen", "cum on", "cum across", "post-orgasm", "aftermath")): - return "leaving the body exposed for visible semen and aftermath" - if any(term in text for term in ("boobjob", "titjob", "breast sex", "handjob", "hand job", "footjob", "testicle", "balls", "penis licking", "non-penetrative")): - return "leaving the contact point unobstructed" - if any(term in text for term in ("oral", "blowjob", "fellatio", "mouth", "tongue")): - return "leaving the oral contact unobstructed" - if any(term in text for term in ("penetrat", "thrust", "penis entering", "vaginal", "anal")): - return "leaving the penetration point unobstructed" - return "leaving skin and body contact readable" - - -def _natural_clothing_state(text: Any, action_text: Any = "") -> str: - text = _clean(text) - if not text: - return "" - text = re.sub(r"^Clothing state:\s*", "", text, flags=re.IGNORECASE) - if re.search(r";\s*(?=(?:Woman|Man) [A-Z]\b)", text): - parts = [ - _natural_clothing_state(part, action_text).rstrip(".") - for part in re.split(r";\s*(?=(?:Woman|Man) [A-Z]\b)", text) - if _clean(part) - ] - return ". ".join(part for part in parts if part) - body_exposure = re.match(r"^Body exposure:\s*(.*?)\.?$", text, flags=re.IGNORECASE) - if body_exposure: - return _clean(body_exposure.group(1)).rstrip(".") - if re.search(r"\bfully nude\b|\bbody is fully exposed\b|\bno clothing covering\b", text, flags=re.IGNORECASE): - owner = "the woman" - owner_match = re.match(r"^\s*((?:Woman|Man) [A-Z])\b", text) - if owner_match: - owner = _natural_label_text(owner_match.group(1), ["Woman A", "Man A"]) or owner - return f"{owner.capitalize()}'s body is fully exposed, bare skin unobstructed" - match = re.match( - r"^(.*?)\b(?:softcore|teaser) outfit is (.*?)(?: for the (?:hardcore|sex) scene)?;\s*(?:softcore visual reference|teaser outfit detail):\s*(.*?)\.?$", - text, - flags=re.IGNORECASE, - ) - if match: - owner = _natural_label_text(match.group(1).strip(" 's"), ["Woman A", "Man A"]).strip() or "the woman" - state = _clean(match.group(2)).lower() - outfit = _clean(match.group(3)).rstrip(".") - if "fully nude" in state or "fully exposed" in state or "no clothing covering" in state: - return f"{owner.capitalize()}'s body is fully exposed, bare skin unobstructed" - if "nude-adjacent" in state: - return f"{owner.capitalize()}'s body is partly exposed" - if "partially removed" in state or "pushed aside" in state: - return f"{owner.capitalize()}'s {outfit} is pushed aside or partly removed where needed, {_clothing_access_phrase(action_text)}" - if "keeps" in state: - return f"{owner.capitalize()} keeps the {outfit} on while {_clothing_access_phrase(action_text)}" - text = re.sub(r";\s*(?:softcore visual reference|teaser outfit detail):\s*", ". Visual clothing state: ", text, flags=re.IGNORECASE) - text = text.replace("softcore outfit", "outfit") - text = text.replace("teaser outfit", "outfit") - text = text.replace("hardcore scene", "sex scene") - return text - - def _axis_values_text(axis_values: Any) -> str: if not isinstance(axis_values, dict): return "" diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 37ee4bc..fc078e2 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -468,6 +468,33 @@ def smoke_insta_pair() -> None: _expect("teaser outfit detail" not in clothing_state, "explicit nude pair should not repeat softcore outfit detail") +def smoke_krea_pair_clothing_state() -> None: + pair = pb.build_insta_of_pair( + row_number=1, + start_index=1, + seed=3511, + ethnicity="any", + figure="random", + no_plus_women=False, + no_black=False, + trigger=Trigger, + prepend_trigger_to_prompt=True, + options_json=_insta_options(hardcore_clothing_continuity="partially_removed"), + character_cast=_character_cast(), + hardcore_position_config=_action_filter("penetration_only"), + ) + _expect_pair(pair, "krea_pair_clothing_state") + krea = krea_formatter.format_krea2_prompt("", metadata_json=_json(pair), target="hardcore") + prompt = _expect_text("krea_pair_clothing_state.krea_prompt", krea.get("krea_prompt"), 60) + lower = prompt.lower() + _expect("metadata" in krea.get("method", ""), "pair clothing route did not use metadata") + _expect("clothing state:" not in lower, "Krea clothing route leaked raw clothing label") + _expect("visual clothing state" not in lower, "Krea clothing route fell back to visual clothing state label") + _expect("softcore outfit" not in lower and "teaser outfit" not in lower, "Krea clothing route leaked softcore outfit label") + _expect("lower body is clear" in lower, "Krea clothing route lost generated clothing continuity") + _expect("the man keeps" in lower, "Krea clothing route lost partner clothing continuity") + + def smoke_insta_pair_pov() -> None: pair = pb.build_insta_of_pair( row_number=1, @@ -654,6 +681,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("hardcore_category_routes", smoke_hardcore_category_routes), ("krea_close_foreplay_route", smoke_krea_close_foreplay_route), ("insta_pair_same_cast", smoke_insta_pair), + ("krea_pair_clothing_state", smoke_krea_pair_clothing_state), ("insta_pair_pov_man", smoke_insta_pair_pov), ("insta_pair_camera_split", smoke_insta_pair_camera_split), ("pov_camera_scene", smoke_pov_camera_scene),