diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 481d8ef..c9ba51c 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -151,6 +151,10 @@ Keep here: Already isolated: +- Insta/OF option normalization, softcore category/outfit/pose pools, partner + outfit pools, clothing-continuity labels, negatives, and hardcore cast count + policy live in `pair_options.py`; `prompt_builder.py` keeps public delegate + wrappers for existing nodes and tests. - soft/hard row creation lives in `pair_rows.py`, including softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, POV row fields, and hardcore row creation. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 3f93cf7..8084826 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -68,6 +68,7 @@ Core helper ownership: | Python module | What it owns | | --- | --- | | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | +| `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, and hardcore cast count policy. | | `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. | @@ -112,7 +113,7 @@ These recipes identify the intended road before editing prompt text. | Generate porn-scene interaction beats | `Hardcore Position Pool` -> `Hardcore Action Filter` -> pair/builder | Use `focus=interaction_only` for kissing/body worship/transitions/guidance/camera/watching/aftercare, or `focus=manual_only` for fingering/clit/manual stimulation; constrain keys such as `camera_showing`, `wrist_pinning`, `fingering`, `aftercare` | `sexual_poses.json` interaction/manual subcategories, `_role_graph`, `krea_action_context.is_foreplay_text` / `krea_actions.hardcore_action_sentence` | | Same woman, same room, softcore and hardcore outputs | `Character Slot/Profile` -> `Insta/OF Options` -> `Insta/OF Prompt Pair` | `continuity=same_creator_same_room`; set `softcore_cast` as needed; use pair metadata into formatter | `build_insta_of_pair`, `softcore_row`, `hardcore_row`, pair metadata fields | | Same cast in softcore and hardcore | Character slot chain -> `Insta/OF Options` | `softcore_cast=same_as_hardcore`; configure partner slots/outfits if needed | `_insta_of_partner_styling`, character slot clothing, pair Krea branch | -| Change only outfit/clothing | Character clothing or category content route | Keep `person_seed`, `scene_seed`, `pose_seed`; change `content_seed`; slot `softcore_outfit` overrides Insta/OF outfit | `SxCP Character Clothing`, `INSTA_OF_SOFTCORE_OUTFITS`, category item templates | +| Change only outfit/clothing | Character clothing or category content route | Keep `person_seed`, `scene_seed`, `pose_seed`; change `content_seed`; slot `softcore_outfit` overrides Insta/OF outfit | `SxCP Character Clothing`, `pair_options.py`, category item templates | | Force a custom location | `SxCP Location Pool` or `SxCP Location Theme` -> builder/pair | `combine_mode=replace` to force; `add` to mix with category scenes | `_scene_pool`, `_apply_location_config_to_legacy_row`, camera scene adapter | | Force a custom frame/composition | `SxCP Composition Pool` or `SxCP Location Theme` -> builder/pair | `combine_mode=replace` to force; `add` to mix | `_composition_pool`, `_apply_composition_config_to_legacy_row`, Krea composition phrase | | Use Qwen/orbit camera geometry | Qwen/orbit node -> camera_config -> builder/pair | For pair, use `softcore_camera_config` and/or `hardcore_camera_config`; set mode from config in options | `_camera_config_with_mode`, `_camera_directive`, `_camera_scene_directive_for_context` | @@ -233,6 +234,7 @@ This table is the first stop when the selected content is wrong. | `sexual_poses.json` foreplay/interaction/manual/oral/outercourse/penetration/etc. | Hardcore action and porn-scene interaction templates, role graphs, axis values, hardcore pool references | `pose` for pose-content route, also `role`; sometimes `content` aliases matter | High because Krea2 rewrites action and POV position text | | `location_pools.json` | Reusable scene pools and legacy scene extensions | `scene` | Medium when a camera-aware adapter changes scene/composition wording | | `expression_composition_pools.json` | Reusable expressions and framing/composition pools | `expression`, `composition` | Medium because formatter may label or suppress expressions | +| `pair_options.py` | Insta/OF option defaults, softcore level-to-category mapping, creator outfit/pose pools, partner outfit pools, negatives, and hard cast count policy | Options node plus `content`/`pose` axes inside pair route | Medium because pair route pools must remain consistent with Krea/SDXL pair formatting | | `generate_prompt_batches.py` legacy pools | Built-in generator clothing, pose, expression, scene, composition lists | Main row seed plus axis config through legacy adapter | Medium because legacy prompt format is field-label heavy | When adding a new pool, choose JSON when the change is pure selectable wording. @@ -387,10 +389,10 @@ flowchart TD Softcore row: -- Category comes from `INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL`. +- Category comes from `pair_options.INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL`. - Outfit comes from character slot `softcore_outfit` if present, otherwise - `INSTA_OF_SOFTCORE_OUTFITS`. -- Soft pose comes from `INSTA_OF_SOFTCORE_POSES`. + `pair_options.INSTA_OF_SOFTCORE_OUTFITS`. +- Soft pose comes from `pair_options.INSTA_OF_SOFTCORE_POSES`. - Partner styling is resolved through `pair_cast.py` using `_insta_of_partner_styling` when softcore cast is `same_as_hardcore`. @@ -789,7 +791,7 @@ pair metadata through the core Python APIs, then verifies: | Symptom | First file/function to inspect | | --- | --- | | Wrong main category/subcategory frequency | Category node config, `category_library.load_category_library`, category JSON weights. | -| Wrong outfit/clothing item | Relevant category JSON, `INSTA_OF_SOFTCORE_OUTFITS`, `SxCP Character Clothing`. | +| Wrong outfit/clothing item | Relevant category JSON, `pair_options.py`, `SxCP Character Clothing`. | | 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. | diff --git a/pair_options.py b/pair_options.py new file mode 100644 index 0000000..be95403 --- /dev/null +++ b/pair_options.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import json +import re +from typing import Any + + +INSTA_OF_SOFT_LEVELS = { + "social_tease": "Instagram-style thirst-trap post, suggestive polished social feed energy", + "lingerie_tease": "premium OF teaser set, lingerie-focused, sensual and intimate", + "implied_nude": "implied nude creator set, strategically covered body and intimate teaser framing", + "explicit_tease": "stronger adult teaser set with bolder nude-adjacent styling and solo-tease framing", + "explicit_nude": "explicit nude creator set with fully nude solo-tease framing", +} + +INSTA_OF_HARDCORE_LEVELS = { + "explicit": "explicit adult creator content with clear sexual contact and adult-only framing", + "hardcore": "hardcore adult creator content with anatomically clear sexual contact and intense body language", +} + +INSTA_OF_PLATFORM_STYLES = { + "hybrid": "hybrid Instagram-to-OF creator shoot, polished social-media framing with intimate subscriber-content energy", + "instagram": "Instagram-inspired creator shoot, polished mirror-selfie and feed-post aesthetics", + "onlyfans": "OnlyFans-inspired creator shoot, intimate subscriber-view camera and candid premium-content framing", +} + +INSTA_OF_HARDCORE_CLOTHING_CONTINUITY = { + "none": "", + "same_outfit": "Woman A keeps her teaser outfit on with the body contact readable", + "partially_removed": "Woman A's teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed", + "implied_nude": "Woman A's body is partly exposed, with fabric slipping off or covering only part of the body", + "explicit_nude": "Woman A's body is fully exposed, bare skin unobstructed", +} + +INSTA_OF_NEGATIVE = ( + "minors, childlike appearance, teen, underage, schoolgirl, non-consensual, coercion, rape, " + "violence, injury, blood, gore, incest, bestiality, watermark, logo, readable username, social media UI" +) + +INSTA_OF_SOFT_NEGATIVE = ( + INSTA_OF_NEGATIVE + + ", explicit intercourse, penetration, oral sex, cumshot, genital contact, group sex, " + "shirtless partner, bare-chested partner, partner nudity" +) + +INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL = { + "social_tease": "Casual clothes / Smart casual", + "lingerie_tease": "Provocative erotic clothes / Provocative lingerie", + "implied_nude": "Provocative erotic clothes / Provocative lingerie", + "explicit_tease": "Provocative erotic clothes / Sheer exposed", + "explicit_nude": "Provocative erotic clothes / Nude accessories", +} + +INSTA_OF_SOFTCORE_OUTFITS = { + "social_tease": [ + "cropped fitted tee, low-rise jeans, delicate jewelry, and polished feed-post styling", + "oversized off-shoulder sweater with fitted shorts and soft lounge socks", + "ribbed tank top, mini skirt, hoop earrings, and casual creator styling", + "silky camisole tucked into relaxed trousers with a subtle waist chain", + "sporty crop top, bike shorts, clean sneakers, and glossy social-feed styling", + "button-down shirt tied at the waist over a fitted bralette and denim shorts", + "body-hugging knit dress with bare shoulders and simple heels", + "relaxed hoodie half-zipped over a crop top with high-cut shorts", + ], + "lingerie_tease": [ + "black lace lingerie set with opaque cups, high-waisted briefs, garter straps, and sheer robe", + "satin bralette and matching high-waisted panties under an oversized shirt", + "lace bodysuit with opaque cups, soft stockings, and delicate garter details", + "silk slip dress with thin straps, thigh slit, and subtle lace trim", + "matching balconette bra and brief set under a loosely draped satin robe", + "velvet lingerie set with covered cups, garter belt, sheer stockings, and small gold accents", + "mesh robe over a covered lace teddy, styled as a premium creator teaser", + "structured corset top with opaque panels, matching briefs, and sheer stockings", + ], + "implied_nude": [ + "oversized white shirt slipping off one shoulder, body mostly covered, bare legs, and soft creator-shot styling", + "towel wrap held across the chest and hips, implied nude but fully covered", + "satin sheet wrapped around the body with shoulders and legs visible but intimate areas covered", + "open robe held closed by hand, implied nude beneath without explicit exposure", + "bath towel and damp hair after a shower, covered chest and hips, intimate creator styling", + "soft blanket wrapped around the body, bare shoulders visible, sensual but covered", + ], + "explicit_tease": [ + "sheer robe over matching lingerie with intimate areas obscured by lace pattern and pose", + "wet-look bodysuit with opaque panels, high-cut legs, and glossy club-light styling", + "transparent mesh dress over covered lingerie, posed as an adult creator teaser", + "lace teddy with strategic opaque embroidery, garter straps, and sheer stockings", + "bare-shoulder robe opened around covered lingerie, bold solo adult tease", + "strappy lingerie set with covered cups and high-waisted bottoms, styled as a stronger solo teaser", + ], + "explicit_nude": [ + "body fully exposed with jewelry accents and direct adult selfie confidence", + "mirror-selfie body exposure with jewelry accents and bold creator-shot framing", + "body fully exposed with direct eye contact and soft creator-shot styling", + "vanity-mirror body exposure with necklace detail and premium creator-shot styling", + "shower-afterglow body exposure with wet hair, skin highlights, and phone-shot framing", + "indoor body exposure with one hand holding the phone and direct camera awareness", + ], +} + +INSTA_OF_SOFTCORE_POSES = { + "social_tease": [ + "taking a mirror selfie with one hip angled and relaxed social-feed confidence", + "leaning against a doorway with one hand holding the phone and a casual teasing smile", + "sitting casually for a polished outfit-check selfie", + "standing by the window with shoulders relaxed and body angled toward the phone", + "posing in a clean feed-post stance with one hand at the waist", + "stretching one arm above the head in a casual morning selfie pose", + ], + "lingerie_tease": [ + "taking a mirror lingerie selfie with one hip angled and the outfit clearly visible", + "kneeling in a covered lingerie teaser pose with hands resting on fabric", + "leaning with the robe draped around covered lingerie", + "standing in a three-quarter lingerie outfit-check pose with legs softly crossed", + "sitting with stockings and garter details visible in a controlled teaser pose", + "turning slightly over one shoulder to show the lingerie silhouette", + ], + "implied_nude": [ + "holding the towel or sheet securely in place while posing for an implied nude selfie", + "sitting with soft fabric wrapped securely around the body and shoulders visible", + "standing by a mirror with a towel wrapped around the body", + "reclining under satin fabric with intimate areas fully obscured", + "holding an open robe closed in a covered implied nude teaser pose", + "looking into the phone camera while wrapped in a blanket with bare shoulders visible", + ], + "explicit_tease": [ + "posing in a stronger adult teaser stance with covered lingerie and direct camera awareness", + "kneeling with a sheer robe arranged around covered lingerie", + "standing close to the mirror with the outfit framed boldly", + "leaning forward slightly with hands on the robe and intimate areas obscured", + "sitting in a bolder covered lingerie pose with direct eye contact", + "arching subtly in a solo adult tease while the styling keeps explicit anatomy obscured", + ], + "explicit_nude": [ + "taking a bold mirror selfie with direct eye contact and the body clearly framed", + "posing with body fully exposed and jewelry accents as styling", + "standing with body fully exposed in a premium creator-shot pose", + "reclining with body fully exposed and the phone held close", + "turning slightly in a mirror pose with the body framed head-to-thigh", + "kneeling in a controlled adult teaser pose with body fully exposed and direct phone-camera awareness", + ], +} + +INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS = [ + "satin slip dress under an oversized shirt", + "soft cardigan over a camisole with relaxed trousers", + "fitted crop top with high-waisted jeans", + "silky robe over a covered bralette and lounge shorts", + "bodycon mini dress with simple heels", + "ribbed tank top with joggers and delicate jewelry", + "oversized tee with fitted shorts and lounge socks", + "button-down shirt with a fitted skirt", +] + +INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS = [ + "fitted black tee with dark jeans", + "buttoned linen shirt with chinos", + "hoodie and joggers", + "open overshirt over a fitted tank with relaxed trousers", + "gym tee with track pants and a towel over one shoulder", + "casual knit shirt with tailored trousers", + "dark crewneck sweater with jeans", + "short-sleeve button-up shirt with relaxed shorts", +] + +SOFTCORE_CAST_POSES = [ + "standing together for a mirror selfie with relaxed close body language", + "posing shoulder-to-shoulder in a creator-shot group teaser", + "leaning together in a polished subscriber preview", + "sitting close together with relaxed hands and styled outfit visibility", + "arranged around Woman A in a flirtatious creator-teaser pose", + "posing together as a coordinated adult creator set", + "standing near the phone tripod with relaxed teasing body language", + "framed together in a softcore cast reveal", +] + + +def _is_false(value: Any) -> bool: + if isinstance(value, bool): + return not value + text = str(value).strip().lower() + return text in {"false", "0", "no", "off", "disabled"} + + +def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float: + try: + number = float(value) + except (TypeError, ValueError): + number = default + return max(min_value, min(max_value, number)) + + +def _normalize_free_text_values(values: Any) -> list[str]: + if isinstance(values, str): + raw_values = [part.strip() for part in re.split(r"[\n;]+", values) if part.strip()] + elif isinstance(values, (list, tuple, set)): + raw_values = list(values) + else: + raw_values = [] + normalized: list[str] = [] + for raw_value in raw_values: + value = str(raw_value or "").strip() + if value and value not in normalized: + normalized.append(value) + return normalized + + +def character_softcore_outfit_values(source: str, custom_outfits: str = "") -> list[str]: + source = str(source or "no_change").strip() + if source in INSTA_OF_SOFTCORE_OUTFITS: + return list(INSTA_OF_SOFTCORE_OUTFITS[source]) + if source == "partner_woman": + return list(INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS) + if source == "partner_man": + return list(INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS) + if source == "custom": + return _normalize_free_text_values(custom_outfits) + return [] + + +def character_hardcore_clothing_values(state: str, custom_clothing: str = "") -> list[str]: + state = str(state or "no_change").strip() + if state == "fully_nude": + return ["fully nude"] + if state == "partly_exposed": + return ["partly nude, body exposed"] + if state == "same_outfit": + return ["keeps the teaser outfit on with the body contact readable"] + if state == "partially_removed": + return ["teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed"] + if state == "custom": + return _normalize_free_text_values(custom_clothing) + return [] + + +def build_insta_of_options_json( + softcore_cast: str = "solo", + hardcore_cast: str = "use_counts", + hardcore_women_count: int = 1, + hardcore_men_count: int = 1, + softcore_level: str = "lingerie_tease", + hardcore_level: str = "hardcore", + platform_style: str = "hybrid", + continuity: str = "same_creator_same_room", + hardcore_clothing_continuity: str = "partially_removed", + softcore_camera_mode: str = "handheld_selfie", + hardcore_camera_mode: str = "from_camera_config", + camera_detail: str = "from_camera_config", + softcore_expression_intensity: float = 0.45, + hardcore_expression_intensity: float = 0.85, + softcore_expression_enabled: bool = True, + hardcore_expression_enabled: bool = True, + hardcore_detail_density: str = "balanced", + hardcore_detail_density_choices: list[str] | tuple[str, ...] = ("compact", "balanced", "dense"), +) -> str: + hardcore_detail_density = ( + hardcore_detail_density if hardcore_detail_density in hardcore_detail_density_choices else "balanced" + ) + return json.dumps( + { + "softcore_cast": softcore_cast, + "hardcore_cast": hardcore_cast, + "hardcore_women_count": int(hardcore_women_count), + "hardcore_men_count": int(hardcore_men_count), + "softcore_level": softcore_level, + "hardcore_level": hardcore_level, + "platform_style": platform_style, + "continuity": continuity, + "hardcore_clothing_continuity": hardcore_clothing_continuity, + "softcore_camera_mode": softcore_camera_mode, + "hardcore_camera_mode": hardcore_camera_mode, + "camera_detail": camera_detail, + "softcore_expression_enabled": not _is_false(softcore_expression_enabled), + "hardcore_expression_enabled": not _is_false(hardcore_expression_enabled), + "softcore_expression_intensity": _clamped_float(softcore_expression_intensity, 0.45), + "hardcore_expression_intensity": _clamped_float(hardcore_expression_intensity, 0.85), + "hardcore_detail_density": hardcore_detail_density, + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def parse_insta_of_options( + options_json: str | dict[str, Any] | None, + *, + camera_mode_choices: dict[str, str] | list[str] | tuple[str, ...], + camera_detail_choices: list[str] | tuple[str, ...], + hardcore_detail_density_choices: list[str] | tuple[str, ...], +) -> dict[str, Any]: + defaults = { + "softcore_cast": "solo", + "hardcore_cast": "use_counts", + "hardcore_women_count": 1, + "hardcore_men_count": 1, + "softcore_level": "lingerie_tease", + "hardcore_level": "hardcore", + "platform_style": "hybrid", + "continuity": "same_creator_same_room", + "hardcore_clothing_continuity": "partially_removed", + "softcore_camera_mode": "handheld_selfie", + "hardcore_camera_mode": "from_camera_config", + "camera_detail": "from_camera_config", + "softcore_expression_enabled": True, + "hardcore_expression_enabled": True, + "softcore_expression_intensity": 0.45, + "hardcore_expression_intensity": 0.85, + "hardcore_detail_density": "balanced", + } + if not options_json: + return defaults + if isinstance(options_json, dict): + raw = options_json + else: + try: + raw = json.loads(str(options_json)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid Insta/OF options JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("Insta/OF options must be a JSON object") + + valid_camera_modes = set(camera_mode_choices) if isinstance(camera_mode_choices, dict) else set(camera_mode_choices) + parsed = {**defaults, **raw} + parsed["softcore_cast"] = parsed["softcore_cast"] if parsed["softcore_cast"] in ("solo", "same_as_hardcore") else defaults["softcore_cast"] + parsed["hardcore_cast"] = parsed["hardcore_cast"] if parsed["hardcore_cast"] in ("use_counts", "couple", "threesome", "group") else defaults["hardcore_cast"] + parsed["softcore_level"] = parsed["softcore_level"] if parsed["softcore_level"] in INSTA_OF_SOFT_LEVELS else defaults["softcore_level"] + parsed["hardcore_level"] = parsed["hardcore_level"] if parsed["hardcore_level"] in INSTA_OF_HARDCORE_LEVELS else defaults["hardcore_level"] + parsed["platform_style"] = parsed["platform_style"] if parsed["platform_style"] in INSTA_OF_PLATFORM_STYLES else defaults["platform_style"] + parsed["continuity"] = parsed["continuity"] if parsed["continuity"] in ("same_creator_same_room", "same_creator_new_scene") else defaults["continuity"] + parsed["hardcore_clothing_continuity"] = ( + parsed["hardcore_clothing_continuity"] + if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY + else defaults["hardcore_clothing_continuity"] + ) + parsed["softcore_camera_mode"] = ( + parsed["softcore_camera_mode"] + if parsed["softcore_camera_mode"] in valid_camera_modes or parsed["softcore_camera_mode"] == "from_camera_config" + else defaults["softcore_camera_mode"] + ) + if ( + parsed["hardcore_camera_mode"] not in valid_camera_modes + and parsed["hardcore_camera_mode"] not in ("from_camera_config", "same_as_softcore") + ): + parsed["hardcore_camera_mode"] = defaults["hardcore_camera_mode"] + parsed["camera_detail"] = ( + parsed["camera_detail"] + if parsed["camera_detail"] in camera_detail_choices or parsed["camera_detail"] == "from_camera_config" + else defaults["camera_detail"] + ) + parsed["softcore_expression_enabled"] = not _is_false(parsed.get("softcore_expression_enabled", True)) + parsed["hardcore_expression_enabled"] = not _is_false(parsed.get("hardcore_expression_enabled", True)) + parsed["softcore_expression_intensity"] = _clamped_float( + parsed.get("softcore_expression_intensity"), + defaults["softcore_expression_intensity"], + ) + parsed["hardcore_expression_intensity"] = _clamped_float( + parsed.get("hardcore_expression_intensity"), + defaults["hardcore_expression_intensity"], + ) + parsed["hardcore_detail_density"] = ( + parsed["hardcore_detail_density"] + if parsed.get("hardcore_detail_density") in hardcore_detail_density_choices + else defaults["hardcore_detail_density"] + ) + for key in ("hardcore_women_count", "hardcore_men_count"): + try: + parsed[key] = max(0, min(12, int(parsed[key]))) + except (TypeError, ValueError): + parsed[key] = defaults[key] + return parsed + + +def hardcore_counts(options: dict[str, Any]) -> tuple[int, int]: + policy = str(options.get("hardcore_cast", "use_counts")) + if policy == "couple": + women_count, men_count = 1, 1 + elif policy == "threesome": + women_count, men_count = 2, 1 + elif policy == "group": + women_count, men_count = 3, 2 + else: + women_count = int(options.get("hardcore_women_count") or 0) + men_count = int(options.get("hardcore_men_count") or 0) + women_count = max(1, min(12, women_count)) + men_count = max(0, min(12, men_count)) + if women_count + men_count < 2: + men_count = 1 + return women_count, men_count + + +def softcore_category(level: str) -> tuple[str, str]: + subcategory = INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL.get( + level, + INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL["lingerie_tease"], + ) + category, _subcategory = subcategory.split(" / ", 1) + return category, subcategory + + +def softcore_outfit_pool(level: str) -> list[str]: + return list(INSTA_OF_SOFTCORE_OUTFITS.get(level, INSTA_OF_SOFTCORE_OUTFITS["lingerie_tease"])) + + +def softcore_pose_pool(level: str) -> list[str]: + return list(INSTA_OF_SOFTCORE_POSES.get(level, INSTA_OF_SOFTCORE_POSES["lingerie_tease"])) + + +def softcore_item_prompt_label(level: str) -> str: + return "Body exposure" if level == "explicit_nude" else "Outfit" diff --git a/prompt_builder.py b/prompt_builder.py index 30833e5..9963e44 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -30,6 +30,7 @@ try: from . import pair_cast from . import pair_output from . import pair_rows + from . import pair_options from . import scene_camera_adapters from .hardcore_text_cleanup import ( sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, @@ -64,6 +65,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import pair_cast import pair_output import pair_rows + import pair_options import scene_camera_adapters from hardcore_text_cleanup import ( sanitize_hardcore_axis_values as _sanitize_hardcore_axis_values, @@ -6492,191 +6494,25 @@ def build_prompt_from_configs( ) -INSTA_OF_SOFT_LEVELS = { - "social_tease": "Instagram-style thirst-trap post, suggestive polished social feed energy", - "lingerie_tease": "premium OF teaser set, lingerie-focused, sensual and intimate", - "implied_nude": "implied nude creator set, strategically covered body and intimate teaser framing", - "explicit_tease": "stronger adult teaser set with bolder nude-adjacent styling and solo-tease framing", - "explicit_nude": "explicit nude creator set with fully nude solo-tease framing", -} - -INSTA_OF_HARDCORE_LEVELS = { - "explicit": "explicit adult creator content with clear sexual contact and adult-only framing", - "hardcore": "hardcore adult creator content with anatomically clear sexual contact and intense body language", -} - -INSTA_OF_PLATFORM_STYLES = { - "hybrid": "hybrid Instagram-to-OF creator shoot, polished social-media framing with intimate subscriber-content energy", - "instagram": "Instagram-inspired creator shoot, polished mirror-selfie and feed-post aesthetics", - "onlyfans": "OnlyFans-inspired creator shoot, intimate subscriber-view camera and candid premium-content framing", -} - -INSTA_OF_HARDCORE_CLOTHING_CONTINUITY = { - "none": "", - "same_outfit": "Woman A keeps her teaser outfit on with the body contact readable", - "partially_removed": "Woman A's teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed", - "implied_nude": "Woman A's body is partly exposed, with fabric slipping off or covering only part of the body", - "explicit_nude": "Woman A's body is fully exposed, bare skin unobstructed", -} - -INSTA_OF_NEGATIVE = ( - "minors, childlike appearance, teen, underage, schoolgirl, non-consensual, coercion, rape, " - "violence, injury, blood, gore, incest, bestiality, watermark, logo, readable username, social media UI" -) - -INSTA_OF_SOFT_NEGATIVE = ( - INSTA_OF_NEGATIVE - + ", explicit intercourse, penetration, oral sex, cumshot, genital contact, group sex, " - "shirtless partner, bare-chested partner, partner nudity" -) - -INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL = { - "social_tease": "Casual clothes / Smart casual", - "lingerie_tease": "Provocative erotic clothes / Provocative lingerie", - "implied_nude": "Provocative erotic clothes / Provocative lingerie", - "explicit_tease": "Provocative erotic clothes / Sheer exposed", - "explicit_nude": "Provocative erotic clothes / Nude accessories", -} - -INSTA_OF_SOFTCORE_OUTFITS = { - "social_tease": [ - "cropped fitted tee, low-rise jeans, delicate jewelry, and polished feed-post styling", - "oversized off-shoulder sweater with fitted shorts and soft lounge socks", - "ribbed tank top, mini skirt, hoop earrings, and casual creator styling", - "silky camisole tucked into relaxed trousers with a subtle waist chain", - "sporty crop top, bike shorts, clean sneakers, and glossy social-feed styling", - "button-down shirt tied at the waist over a fitted bralette and denim shorts", - "body-hugging knit dress with bare shoulders and simple heels", - "relaxed hoodie half-zipped over a crop top with high-cut shorts", - ], - "lingerie_tease": [ - "black lace lingerie set with opaque cups, high-waisted briefs, garter straps, and sheer robe", - "satin bralette and matching high-waisted panties under an oversized shirt", - "lace bodysuit with opaque cups, soft stockings, and delicate garter details", - "silk slip dress with thin straps, thigh slit, and subtle lace trim", - "matching balconette bra and brief set under a loosely draped satin robe", - "velvet lingerie set with covered cups, garter belt, sheer stockings, and small gold accents", - "mesh robe over a covered lace teddy, styled as a premium creator teaser", - "structured corset top with opaque panels, matching briefs, and sheer stockings", - ], - "implied_nude": [ - "oversized white shirt slipping off one shoulder, body mostly covered, bare legs, and soft creator-shot styling", - "towel wrap held across the chest and hips, implied nude but fully covered", - "satin sheet wrapped around the body with shoulders and legs visible but intimate areas covered", - "open robe held closed by hand, implied nude beneath without explicit exposure", - "bath towel and damp hair after a shower, covered chest and hips, intimate creator styling", - "soft blanket wrapped around the body, bare shoulders visible, sensual but covered", - ], - "explicit_tease": [ - "sheer robe over matching lingerie with intimate areas obscured by lace pattern and pose", - "wet-look bodysuit with opaque panels, high-cut legs, and glossy club-light styling", - "transparent mesh dress over covered lingerie, posed as an adult creator teaser", - "lace teddy with strategic opaque embroidery, garter straps, and sheer stockings", - "bare-shoulder robe opened around covered lingerie, bold solo adult tease", - "strappy lingerie set with covered cups and high-waisted bottoms, styled as a stronger solo teaser", - ], - "explicit_nude": [ - "body fully exposed with jewelry accents and direct adult selfie confidence", - "mirror-selfie body exposure with jewelry accents and bold creator-shot framing", - "body fully exposed with direct eye contact and soft creator-shot styling", - "vanity-mirror body exposure with necklace detail and premium creator-shot styling", - "shower-afterglow body exposure with wet hair, skin highlights, and phone-shot framing", - "indoor body exposure with one hand holding the phone and direct camera awareness", - ], -} - -INSTA_OF_SOFTCORE_POSES = { - "social_tease": [ - "taking a mirror selfie with one hip angled and relaxed social-feed confidence", - "leaning against a doorway with one hand holding the phone and a casual teasing smile", - "sitting casually for a polished outfit-check selfie", - "standing by the window with shoulders relaxed and body angled toward the phone", - "posing in a clean feed-post stance with one hand at the waist", - "stretching one arm above the head in a casual morning selfie pose", - ], - "lingerie_tease": [ - "taking a mirror lingerie selfie with one hip angled and the outfit clearly visible", - "kneeling in a covered lingerie teaser pose with hands resting on fabric", - "leaning with the robe draped around covered lingerie", - "standing in a three-quarter lingerie outfit-check pose with legs softly crossed", - "sitting with stockings and garter details visible in a controlled teaser pose", - "turning slightly over one shoulder to show the lingerie silhouette", - ], - "implied_nude": [ - "holding the towel or sheet securely in place while posing for an implied nude selfie", - "sitting with soft fabric wrapped securely around the body and shoulders visible", - "standing by a mirror with a towel wrapped around the body", - "reclining under satin fabric with intimate areas fully obscured", - "holding an open robe closed in a covered implied nude teaser pose", - "looking into the phone camera while wrapped in a blanket with bare shoulders visible", - ], - "explicit_tease": [ - "posing in a stronger adult teaser stance with covered lingerie and direct camera awareness", - "kneeling with a sheer robe arranged around covered lingerie", - "standing close to the mirror with the outfit framed boldly", - "leaning forward slightly with hands on the robe and intimate areas obscured", - "sitting in a bolder covered lingerie pose with direct eye contact", - "arching subtly in a solo adult tease while the styling keeps explicit anatomy obscured", - ], - "explicit_nude": [ - "taking a bold mirror selfie with direct eye contact and the body clearly framed", - "posing with body fully exposed and jewelry accents as styling", - "standing with body fully exposed in a premium creator-shot pose", - "reclining with body fully exposed and the phone held close", - "turning slightly in a mirror pose with the body framed head-to-thigh", - "kneeling in a controlled adult teaser pose with body fully exposed and direct phone-camera awareness", - ], -} - -INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS = [ - "satin slip dress under an oversized shirt", - "soft cardigan over a camisole with relaxed trousers", - "fitted crop top with high-waisted jeans", - "silky robe over a covered bralette and lounge shorts", - "bodycon mini dress with simple heels", - "ribbed tank top with joggers and delicate jewelry", - "oversized tee with fitted shorts and lounge socks", - "button-down shirt with a fitted skirt", -] - -INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS = [ - "fitted black tee with dark jeans", - "buttoned linen shirt with chinos", - "hoodie and joggers", - "open overshirt over a fitted tank with relaxed trousers", - "gym tee with track pants and a towel over one shoulder", - "casual knit shirt with tailored trousers", - "dark crewneck sweater with jeans", - "short-sleeve button-up shirt with relaxed shorts", -] +INSTA_OF_SOFT_LEVELS = pair_options.INSTA_OF_SOFT_LEVELS +INSTA_OF_HARDCORE_LEVELS = pair_options.INSTA_OF_HARDCORE_LEVELS +INSTA_OF_PLATFORM_STYLES = pair_options.INSTA_OF_PLATFORM_STYLES +INSTA_OF_HARDCORE_CLOTHING_CONTINUITY = pair_options.INSTA_OF_HARDCORE_CLOTHING_CONTINUITY +INSTA_OF_NEGATIVE = pair_options.INSTA_OF_NEGATIVE +INSTA_OF_SOFT_NEGATIVE = pair_options.INSTA_OF_SOFT_NEGATIVE +INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL = pair_options.INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL +INSTA_OF_SOFTCORE_OUTFITS = pair_options.INSTA_OF_SOFTCORE_OUTFITS +INSTA_OF_SOFTCORE_POSES = pair_options.INSTA_OF_SOFTCORE_POSES +INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS = pair_options.INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS +INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS = pair_options.INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS def character_softcore_outfit_values(source: str, custom_outfits: str = "") -> list[str]: - source = str(source or "no_change").strip() - if source in INSTA_OF_SOFTCORE_OUTFITS: - return list(INSTA_OF_SOFTCORE_OUTFITS[source]) - if source == "partner_woman": - return list(INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS) - if source == "partner_man": - return list(INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS) - if source == "custom": - return _normalize_characteristic_values(custom_outfits, None, allow_free_text=True) - return [] + return pair_options.character_softcore_outfit_values(source, custom_outfits) def character_hardcore_clothing_values(state: str, custom_clothing: str = "") -> list[str]: - state = str(state or "no_change").strip() - if state == "fully_nude": - return ["fully nude"] - if state == "partly_exposed": - return ["partly nude, body exposed"] - if state == "same_outfit": - return ["keeps the teaser outfit on with the body contact readable"] - if state == "partially_removed": - return ["teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed"] - if state == "custom": - return _normalize_characteristic_values(custom_clothing, None, allow_free_text=True) - return [] + return pair_options.character_hardcore_clothing_values(state, custom_clothing) def build_insta_of_options_json( @@ -6698,131 +6534,39 @@ def build_insta_of_options_json( hardcore_expression_enabled: bool = True, hardcore_detail_density: str = "balanced", ) -> str: - hardcore_detail_density = ( - hardcore_detail_density if hardcore_detail_density in HARDCORE_DETAIL_DENSITY_CHOICES else "balanced" - ) - return json.dumps( - { - "softcore_cast": softcore_cast, - "hardcore_cast": hardcore_cast, - "hardcore_women_count": int(hardcore_women_count), - "hardcore_men_count": int(hardcore_men_count), - "softcore_level": softcore_level, - "hardcore_level": hardcore_level, - "platform_style": platform_style, - "continuity": continuity, - "hardcore_clothing_continuity": hardcore_clothing_continuity, - "softcore_camera_mode": softcore_camera_mode, - "hardcore_camera_mode": hardcore_camera_mode, - "camera_detail": camera_detail, - "softcore_expression_enabled": not _is_false(softcore_expression_enabled), - "hardcore_expression_enabled": not _is_false(hardcore_expression_enabled), - "softcore_expression_intensity": _clamped_float(softcore_expression_intensity, 0.45), - "hardcore_expression_intensity": _clamped_float(hardcore_expression_intensity, 0.85), - "hardcore_detail_density": hardcore_detail_density, - }, - ensure_ascii=True, - sort_keys=True, + return pair_options.build_insta_of_options_json( + softcore_cast=softcore_cast, + hardcore_cast=hardcore_cast, + hardcore_women_count=hardcore_women_count, + hardcore_men_count=hardcore_men_count, + softcore_level=softcore_level, + hardcore_level=hardcore_level, + platform_style=platform_style, + continuity=continuity, + hardcore_clothing_continuity=hardcore_clothing_continuity, + softcore_camera_mode=softcore_camera_mode, + hardcore_camera_mode=hardcore_camera_mode, + camera_detail=camera_detail, + softcore_expression_intensity=softcore_expression_intensity, + hardcore_expression_intensity=hardcore_expression_intensity, + softcore_expression_enabled=softcore_expression_enabled, + hardcore_expression_enabled=hardcore_expression_enabled, + hardcore_detail_density=hardcore_detail_density, + hardcore_detail_density_choices=HARDCORE_DETAIL_DENSITY_CHOICES, ) def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[str, Any]: - defaults = { - "softcore_cast": "solo", - "hardcore_cast": "use_counts", - "hardcore_women_count": 1, - "hardcore_men_count": 1, - "softcore_level": "lingerie_tease", - "hardcore_level": "hardcore", - "platform_style": "hybrid", - "continuity": "same_creator_same_room", - "hardcore_clothing_continuity": "partially_removed", - "softcore_camera_mode": "handheld_selfie", - "hardcore_camera_mode": "from_camera_config", - "camera_detail": "from_camera_config", - "softcore_expression_enabled": True, - "hardcore_expression_enabled": True, - "softcore_expression_intensity": 0.45, - "hardcore_expression_intensity": 0.85, - "hardcore_detail_density": "balanced", - } - if not options_json: - return defaults - if isinstance(options_json, dict): - raw = options_json - else: - try: - raw = json.loads(str(options_json)) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid Insta/OF options JSON: {exc}") from exc - if not isinstance(raw, dict): - raise ValueError("Insta/OF options must be a JSON object") - parsed = {**defaults, **raw} - parsed["softcore_cast"] = parsed["softcore_cast"] if parsed["softcore_cast"] in ("solo", "same_as_hardcore") else defaults["softcore_cast"] - parsed["hardcore_cast"] = parsed["hardcore_cast"] if parsed["hardcore_cast"] in ("use_counts", "couple", "threesome", "group") else defaults["hardcore_cast"] - parsed["softcore_level"] = parsed["softcore_level"] if parsed["softcore_level"] in INSTA_OF_SOFT_LEVELS else defaults["softcore_level"] - parsed["hardcore_level"] = parsed["hardcore_level"] if parsed["hardcore_level"] in INSTA_OF_HARDCORE_LEVELS else defaults["hardcore_level"] - parsed["platform_style"] = parsed["platform_style"] if parsed["platform_style"] in INSTA_OF_PLATFORM_STYLES else defaults["platform_style"] - parsed["continuity"] = parsed["continuity"] if parsed["continuity"] in ("same_creator_same_room", "same_creator_new_scene") else defaults["continuity"] - parsed["hardcore_clothing_continuity"] = ( - parsed["hardcore_clothing_continuity"] - if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY - else defaults["hardcore_clothing_continuity"] + return pair_options.parse_insta_of_options( + options_json, + camera_mode_choices=CAMERA_MODE_PROMPTS, + camera_detail_choices=CAMERA_DETAIL_CHOICES, + hardcore_detail_density_choices=HARDCORE_DETAIL_DENSITY_CHOICES, ) - parsed["softcore_camera_mode"] = ( - parsed["softcore_camera_mode"] - if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS or parsed["softcore_camera_mode"] == "from_camera_config" - else defaults["softcore_camera_mode"] - ) - if ( - parsed["hardcore_camera_mode"] not in CAMERA_MODE_PROMPTS - and parsed["hardcore_camera_mode"] not in ("from_camera_config", "same_as_softcore") - ): - parsed["hardcore_camera_mode"] = defaults["hardcore_camera_mode"] - parsed["camera_detail"] = ( - parsed["camera_detail"] - if parsed["camera_detail"] in CAMERA_DETAIL_CHOICES or parsed["camera_detail"] == "from_camera_config" - else defaults["camera_detail"] - ) - parsed["softcore_expression_enabled"] = not _is_false(parsed.get("softcore_expression_enabled", True)) - parsed["hardcore_expression_enabled"] = not _is_false(parsed.get("hardcore_expression_enabled", True)) - parsed["softcore_expression_intensity"] = _clamped_float( - parsed.get("softcore_expression_intensity"), - defaults["softcore_expression_intensity"], - ) - parsed["hardcore_expression_intensity"] = _clamped_float( - parsed.get("hardcore_expression_intensity"), - defaults["hardcore_expression_intensity"], - ) - parsed["hardcore_detail_density"] = ( - parsed["hardcore_detail_density"] - if parsed.get("hardcore_detail_density") in HARDCORE_DETAIL_DENSITY_CHOICES - else defaults["hardcore_detail_density"] - ) - for key in ("hardcore_women_count", "hardcore_men_count"): - try: - parsed[key] = max(0, min(12, int(parsed[key]))) - except (TypeError, ValueError): - parsed[key] = defaults[key] - return parsed def _insta_of_hardcore_counts(options: dict[str, Any]) -> tuple[int, int]: - policy = str(options.get("hardcore_cast", "use_counts")) - if policy == "couple": - women_count, men_count = 1, 1 - elif policy == "threesome": - women_count, men_count = 2, 1 - elif policy == "group": - women_count, men_count = 3, 2 - else: - women_count = int(options.get("hardcore_women_count") or 0) - men_count = int(options.get("hardcore_men_count") or 0) - women_count = max(1, min(12, women_count)) - men_count = max(0, min(12, men_count)) - if women_count + men_count < 2: - men_count = 1 - return women_count, men_count + return pair_options.hardcore_counts(options) def _insta_of_descriptor(row: dict[str, Any]) -> str: @@ -6888,39 +6632,23 @@ def _insta_of_prompt_cast_descriptors(text: str) -> str: return str(text or "").replace("Woman A / primary creator:", "Woman A:") -SOFTCORE_CAST_POSES = [ - "standing together for a mirror selfie with relaxed close body language", - "posing shoulder-to-shoulder in a creator-shot group teaser", - "leaning together in a polished subscriber preview", - "sitting close together with relaxed hands and styled outfit visibility", - "arranged around Woman A in a flirtatious creator-teaser pose", - "posing together as a coordinated adult creator set", - "standing near the phone tripod with relaxed teasing body language", - "framed together in a softcore cast reveal", -] +SOFTCORE_CAST_POSES = pair_options.SOFTCORE_CAST_POSES def _insta_of_softcore_category(level: str) -> tuple[str, str]: - subcategory = INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL.get( - level, - INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL["lingerie_tease"], - ) - category, _subcategory = subcategory.split(" / ", 1) - return category, subcategory + return pair_options.softcore_category(level) def _insta_of_softcore_outfit(rng: random.Random, level: str) -> str: - pool = INSTA_OF_SOFTCORE_OUTFITS.get(level, INSTA_OF_SOFTCORE_OUTFITS["lingerie_tease"]) - return g.choose(rng, pool) + return g.choose(rng, pair_options.softcore_outfit_pool(level)) def _insta_of_softcore_item_prompt_label(level: str) -> str: - return "Body exposure" if level == "explicit_nude" else "Outfit" + return pair_options.softcore_item_prompt_label(level) def _insta_of_softcore_pose(rng: random.Random, level: str) -> str: - pool = INSTA_OF_SOFTCORE_POSES.get(level, INSTA_OF_SOFTCORE_POSES["lingerie_tease"]) - return g.choose(rng, pool) + return g.choose(rng, pair_options.softcore_pose_pool(level)) def _insta_of_partner_styling( diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 1210252..5f96ab9 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -627,6 +627,61 @@ def _insta_options(**overrides: Any) -> str: return _json(data) +def smoke_pair_options_policy() -> None: + _expect( + pb.INSTA_OF_SOFTCORE_OUTFITS is pb.pair_options.INSTA_OF_SOFTCORE_OUTFITS, + "prompt_builder should delegate Insta/OF softcore outfit policy to pair_options", + ) + options = json.loads( + pb.build_insta_of_options_json( + softcore_expression_enabled="false", + hardcore_expression_enabled="0", + softcore_expression_intensity=1.4, + hardcore_expression_intensity=-0.4, + hardcore_detail_density="invalid", + ) + ) + _expect(options["softcore_expression_enabled"] is False, "softcore expression enabled should normalize false strings") + _expect(options["hardcore_expression_enabled"] is False, "hardcore expression enabled should normalize false strings") + _expect(options["softcore_expression_intensity"] == 1.0, "softcore expression intensity should clamp high values") + _expect(options["hardcore_expression_intensity"] == 0.0, "hardcore expression intensity should clamp low values") + _expect(options["hardcore_detail_density"] == "balanced", "invalid hardcore detail density should fallback") + + parsed = pb._parse_insta_of_options( + { + "softcore_cast": "bad", + "hardcore_cast": "bad", + "softcore_camera_mode": "bad", + "hardcore_camera_mode": "bad", + "camera_detail": "bad", + "hardcore_detail_density": "bad", + "hardcore_women_count": "20", + "hardcore_men_count": "-3", + } + ) + _expect(parsed["softcore_cast"] == "solo", "invalid softcore cast should fallback") + _expect(parsed["hardcore_cast"] == "use_counts", "invalid hardcore cast should fallback") + _expect(parsed["softcore_camera_mode"] == "handheld_selfie", "invalid softcore camera should fallback") + _expect(parsed["hardcore_camera_mode"] == "from_camera_config", "invalid hardcore camera should fallback") + _expect(parsed["camera_detail"] == "from_camera_config", "invalid camera detail should fallback") + _expect(parsed["hardcore_detail_density"] == "balanced", "invalid hardcore density should fallback on parse") + _expect(parsed["hardcore_women_count"] == 12, "women count should clamp to max") + _expect(parsed["hardcore_men_count"] == 0, "men count should clamp to min") + + _expect(pb.character_softcore_outfit_values("partner_man"), "partner man softcore outfit pool should not be empty") + _expect( + pb.character_softcore_outfit_values("custom", "one; two\nthree") == ["one", "two", "three"], + "custom softcore outfits should split stable free-text lists", + ) + _expect("fully nude" in pb.character_hardcore_clothing_values("fully_nude"), "fully nude clothing state should be exposed") + _expect( + pb.character_hardcore_clothing_values("custom", "bare; outfit pushed aside") == ["bare", "outfit pushed aside"], + "custom hardcore clothing should split stable free-text lists", + ) + _expect(pb._insta_of_hardcore_counts({"hardcore_cast": "threesome"}) == (2, 1), "threesome count policy changed") + _expect(pb._insta_of_softcore_category("social_tease") == ("Casual clothes", "Casual clothes / Smart casual"), "softcore category mapping changed") + + def _expect_pair(pair: dict[str, Any], name: str) -> None: _expect(pair.get("mode") == "Insta/OF", f"{name}.mode should be Insta/OF") _expect_row_base(pair.get("softcore_row") or {}, f"{name}.softcore_row") @@ -2314,6 +2369,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("category_library_route", smoke_category_library_route), ("hardcore_category_routes", smoke_hardcore_category_routes), ("krea_close_foreplay_route", smoke_krea_close_foreplay_route), + ("pair_options_policy", smoke_pair_options_policy), ("insta_pair_same_cast", smoke_insta_pair), ("krea_pair_clothing_state", smoke_krea_pair_clothing_state), ("insta_pair_pov_man", smoke_insta_pair_pov),