diff --git a/__init__.py b/__init__.py index 963fc56..b6c5cf5 100644 --- a/__init__.py +++ b/__init__.py @@ -11,6 +11,7 @@ except Exception: PromptServer = None SXCP_HAIR_CONFIG = "SXCP_HAIR_CONFIG" +SXCP_CHARACTERISTICS = "SXCP_CHARACTERISTICS" SXCP_CHARACTER_MANUAL = "SXCP_CHARACTER_MANUAL" SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST" SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG" @@ -35,6 +36,7 @@ try: build_character_slot_json, build_character_manual_config_json, build_character_profile_json, + build_characteristics_config_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, @@ -63,6 +65,7 @@ try: character_body_choices, character_descriptor_detail_choices, character_ethnicity_choices, + character_eye_color_choices, character_figure_choices, character_hair_color_choices, character_hair_length_choices, @@ -71,6 +74,10 @@ try: character_man_body_choices, character_presence_choices, character_profile_choices, + character_hardcore_clothing_state_choices, + character_hardcore_clothing_values, + character_softcore_outfit_source_choices, + character_softcore_outfit_values, character_woman_body_choices, ethnicity_choices, generation_profile_choices, @@ -93,6 +100,7 @@ except ImportError: build_character_slot_json, build_character_manual_config_json, build_character_profile_json, + build_characteristics_config_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, @@ -121,6 +129,7 @@ except ImportError: character_body_choices, character_descriptor_detail_choices, character_ethnicity_choices, + character_eye_color_choices, character_figure_choices, character_hair_color_choices, character_hair_length_choices, @@ -129,6 +138,10 @@ except ImportError: character_man_body_choices, character_presence_choices, character_profile_choices, + character_hardcore_clothing_state_choices, + character_hardcore_clothing_values, + character_softcore_outfit_source_choices, + character_softcore_outfit_values, character_woman_body_choices, ethnicity_choices, generation_profile_choices, @@ -880,6 +893,190 @@ class SxCPHairStyle(_SxCPHairAxisNode): AXIS = "style" +def _choice_input_key(prefix, choice): + key = "".join(char if char.isalnum() else "_" for char in str(choice).lower()).strip("_") + while "__" in key: + key = key.replace("__", "_") + return f"{prefix}_{key}" + + +class SxCPCharacterAgeRange: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}), + "min_age": ("INT", {"default": 21, "min": 21, "max": 85, "step": 1}), + "max_age": ("INT", {"default": 35, "min": 21, "max": 85, "step": 1}), + }, + "optional": { + "characteristics": (SXCP_CHARACTERISTICS,), + }, + } + + RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING") + RETURN_NAMES = ("characteristics", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, combine_mode, min_age, max_age, characteristics=""): + start = max(21, min(85, int(min_age))) + end = max(21, min(85, int(max_age))) + if end < start: + start, end = end, start + ages = [f"{age}-year-old adult" for age in range(start, end + 1)] + config = build_characteristics_config_json( + characteristics=characteristics or "", + axis="ages", + selected_values=ages, + combine_mode=combine_mode, + ) + return config, json.loads(config).get("summary", "") + + +class _SxCPBodyPoolNode: + SUBJECT = "character" + + @classmethod + def _choices(cls): + if cls.SUBJECT == "woman": + return [choice for choice in character_woman_body_choices() if choice not in ("random", "manual")] + if cls.SUBJECT == "man": + return [choice for choice in character_man_body_choices() if choice not in ("random", "manual")] + return [choice for choice in character_body_choices() if choice not in ("random", "manual")] + + @classmethod + def INPUT_TYPES(cls): + required = { + "combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}), + } + for choice in cls._choices(): + required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False}) + return { + "required": required, + "optional": { + "characteristics": (SXCP_CHARACTERISTICS,), + }, + } + + RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING") + RETURN_NAMES = ("characteristics", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, combine_mode="replace_axis", characteristics="", **kwargs): + selected = [ + choice + for choice in self._choices() + if bool(kwargs.get(_choice_input_key("include", choice), False)) + ] + config = build_characteristics_config_json( + characteristics=characteristics or "", + axis="bodies", + selected_values=selected, + combine_mode=combine_mode, + ) + return config, json.loads(config).get("summary", "") + + +class SxCPCharacterBodyPool(_SxCPBodyPoolNode): + SUBJECT = "character" + + +class SxCPWomanBodyPool(_SxCPBodyPoolNode): + SUBJECT = "woman" + + +class SxCPManBodyPool(_SxCPBodyPoolNode): + SUBJECT = "man" + + +class SxCPEyeColorPool: + @classmethod + def INPUT_TYPES(cls): + required = { + "combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}), + } + for choice in character_eye_color_choices(): + if choice != "random": + required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False}) + return { + "required": required, + "optional": { + "characteristics": (SXCP_CHARACTERISTICS,), + }, + } + + RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING") + RETURN_NAMES = ("characteristics", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, combine_mode="replace_axis", characteristics="", **kwargs): + selected = [ + choice + for choice in character_eye_color_choices() + if choice != "random" and bool(kwargs.get(_choice_input_key("include", choice), False)) + ] + config = build_characteristics_config_json( + characteristics=characteristics or "", + axis="eyes", + selected_values=selected, + combine_mode=combine_mode, + ) + return config, json.loads(config).get("summary", "") + + +class SxCPCharacterClothing: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}), + "softcore_source": (character_softcore_outfit_source_choices(), {"default": "no_change"}), + "hardcore_state": (character_hardcore_clothing_state_choices(), {"default": "no_change"}), + "custom_softcore_outfits": ("STRING", {"default": "", "multiline": True}), + "custom_hardcore_clothing": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "characteristics": (SXCP_CHARACTERISTICS,), + }, + } + + RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING") + RETURN_NAMES = ("characteristics", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + combine_mode, + softcore_source, + hardcore_state, + custom_softcore_outfits, + custom_hardcore_clothing, + characteristics="", + ): + config = characteristics or "" + if softcore_source != "no_change": + config = build_characteristics_config_json( + characteristics=config, + axis="softcore_outfits", + selected_values=character_softcore_outfit_values(softcore_source, custom_softcore_outfits), + combine_mode=combine_mode, + ) + if hardcore_state != "no_change": + config = build_characteristics_config_json( + characteristics=config, + axis="hardcore_clothing", + selected_values=character_hardcore_clothing_values(hardcore_state, custom_hardcore_clothing), + combine_mode=combine_mode, + ) + if not config: + config = build_characteristics_config_json(axis="", selected_values=[]) + return config, json.loads(config).get("summary", "") + + class SxCPCharacterManualDetails: @classmethod def INPUT_TYPES(cls): @@ -1028,6 +1225,7 @@ class SxCPCharacterSlot: "optional": { "manual": (SXCP_CHARACTER_MANUAL,), "ethnicity_list": (SXCP_ETHNICITY_LIST,), + "characteristics": (SXCP_CHARACTERISTICS,), "hair_config": (SXCP_HAIR_CONFIG,), "character_cast": (SXCP_CHARACTER_CAST,), }, @@ -1056,6 +1254,7 @@ class SxCPCharacterSlot: hardcore_expression_intensity=-1.0, character_cast="", ethnicity_list="", + characteristics="", hair_config="", manual="", ): @@ -1072,6 +1271,7 @@ class SxCPCharacterSlot: body_phrase="", skin="", hair="", + characteristics=characteristics, hair_config=hair_config, eyes="", descriptor_detail=descriptor_detail, @@ -1109,6 +1309,7 @@ class SxCPWomanSlot: "optional": { "manual": (SXCP_CHARACTER_MANUAL,), "ethnicity_list": (SXCP_ETHNICITY_LIST,), + "characteristics": (SXCP_CHARACTERISTICS,), "hair_config": (SXCP_HAIR_CONFIG,), "character_cast": (SXCP_CHARACTER_CAST,), }, @@ -1135,6 +1336,7 @@ class SxCPWomanSlot: hardcore_expression_intensity=-1.0, character_cast="", ethnicity_list="", + characteristics="", hair_config="", manual="", ): @@ -1151,6 +1353,7 @@ class SxCPWomanSlot: body_phrase="", skin="", hair="", + characteristics=characteristics, hair_config=hair_config, eyes="", descriptor_detail=descriptor_detail, @@ -1187,6 +1390,7 @@ class SxCPManSlot: "optional": { "manual": (SXCP_CHARACTER_MANUAL,), "ethnicity_list": (SXCP_ETHNICITY_LIST,), + "characteristics": (SXCP_CHARACTERISTICS,), "hair_config": (SXCP_HAIR_CONFIG,), "character_cast": (SXCP_CHARACTER_CAST,), }, @@ -1213,6 +1417,7 @@ class SxCPManSlot: hardcore_expression_intensity=-1.0, character_cast="", ethnicity_list="", + characteristics="", hair_config="", manual="", ): @@ -1229,6 +1434,7 @@ class SxCPManSlot: body_phrase="", skin="", hair="", + characteristics=characteristics, hair_config=hair_config, eyes="", descriptor_detail=descriptor_detail, @@ -1702,6 +1908,12 @@ NODE_CLASS_MAPPINGS = { "SxCPHairLength": SxCPHairLength, "SxCPHairColor": SxCPHairColor, "SxCPHairStyle": SxCPHairStyle, + "SxCPCharacterAgeRange": SxCPCharacterAgeRange, + "SxCPCharacterBodyPool": SxCPCharacterBodyPool, + "SxCPWomanBodyPool": SxCPWomanBodyPool, + "SxCPManBodyPool": SxCPManBodyPool, + "SxCPEyeColorPool": SxCPEyeColorPool, + "SxCPCharacterClothing": SxCPCharacterClothing, "SxCPCharacterManualDetails": SxCPCharacterManualDetails, "SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, @@ -1732,6 +1944,12 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPHairLength": "SxCP Hair Length", "SxCPHairColor": "SxCP Hair Color", "SxCPHairStyle": "SxCP Hair Style/Cut", + "SxCPCharacterAgeRange": "SxCP Character Age Range", + "SxCPCharacterBodyPool": "SxCP Character Body Pool", + "SxCPWomanBodyPool": "SxCP Woman Body Pool", + "SxCPManBodyPool": "SxCP Man Body Pool", + "SxCPEyeColorPool": "SxCP Eye Color Pool", + "SxCPCharacterClothing": "SxCP Character Clothing", "SxCPCharacterManualDetails": "SxCP Character Manual Details", "SxCPAdvancedFilters": "SxCP Advanced Filters", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", diff --git a/prompt_builder.py b/prompt_builder.py index 07cec5a..d76b446 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -271,6 +271,27 @@ CHARACTER_HAIR_STYLE_CHOICES = [ "wet_hair", "slicked_back", ] +CHARACTER_EYE_COLOR_CHOICES = [ + "random", + "blue", + "pale_blue", + "ice_blue", + "blue_gray", + "green", + "emerald_green", + "hazel", + "light_hazel", + "green_hazel", + "amber", + "amber_brown", + "honey_brown", + "brown", + "deep_brown", + "dark_brown", + "dark", + "gray", + "gray_brown", +] CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] @@ -1761,6 +1782,10 @@ def character_hair_style_choices() -> list[str]: return list(CHARACTER_HAIR_STYLE_CHOICES) +def character_eye_color_choices() -> list[str]: + return list(CHARACTER_EYE_COLOR_CHOICES) + + def character_ethnicity_choices() -> list[str]: return ["random"] + list(ETHNICITY_FILTER_CHOICES) @@ -1777,6 +1802,31 @@ def hardcore_detail_density_choices() -> list[str]: return list(HARDCORE_DETAIL_DENSITY_CHOICES) +def character_softcore_outfit_source_choices() -> list[str]: + return [ + "no_change", + "social_tease", + "lingerie_tease", + "implied_nude", + "explicit_tease", + "explicit_nude", + "partner_woman", + "partner_man", + "custom", + ] + + +def character_hardcore_clothing_state_choices() -> list[str]: + return [ + "no_change", + "fully_nude", + "partly_exposed", + "same_outfit", + "partially_removed", + "custom", + ] + + def camera_orbit_framing_choices() -> list[str]: return list(CAMERA_ORBIT_FRAMING_CHOICES) @@ -2565,6 +2615,157 @@ def _slot_value(value: Any) -> str: return text +CHARACTER_CHARACTERISTIC_AXES = { + "ages": CHARACTER_AGE_CHOICES, + "bodies": list(dict.fromkeys([*CHARACTER_BODY_CHOICES, *CHARACTER_WOMAN_BODY_CHOICES, *CHARACTER_MAN_BODY_CHOICES])), + "eyes": CHARACTER_EYE_COLOR_CHOICES, +} + + +def _empty_characteristics_config() -> dict[str, Any]: + return { + "config_type": "characteristics", + "ages": [], + "bodies": [], + "eyes": [], + "softcore_outfits": [], + "hardcore_clothing": [], + } + + +def _normalize_characteristic_choice(value: Any, choices: list[str] | tuple[str, ...]) -> str: + text = str(value or "").strip() + if not text: + return "" + normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") + for choice in choices: + if normalized == re.sub(r"[^a-z0-9]+", "_", str(choice).lower()).strip("_"): + return str(choice) + return "" + + +def _normalize_characteristic_values( + values: Any, + choices: list[str] | tuple[str, ...] | None = None, + *, + allow_free_text: bool = False, +) -> list[str]: + if isinstance(values, str): + raw_values = [part.strip() for part in re.split(r"[\n;]+", values) if part.strip()] + if len(raw_values) == 1 and "," in raw_values[0] and not allow_free_text: + raw_values = [part.strip() for part in raw_values[0].split(",") 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 choices is None else _normalize_characteristic_choice(raw_value, choices) + if not value or value in ("random", "manual"): + continue + if value not in normalized: + normalized.append(value) + return normalized + + +def _parse_characteristics_config(value: str | dict[str, Any] | None) -> dict[str, Any]: + if not value: + return _empty_characteristics_config() + if isinstance(value, dict): + raw = value + else: + try: + raw = json.loads(str(value)) + except json.JSONDecodeError: + return _empty_characteristics_config() + if not isinstance(raw, dict): + return _empty_characteristics_config() + return { + "config_type": "characteristics", + "ages": _normalize_characteristic_values(raw.get("ages"), CHARACTER_AGE_CHOICES), + "bodies": _normalize_characteristic_values(raw.get("bodies"), CHARACTER_CHARACTERISTIC_AXES["bodies"]), + "eyes": _normalize_characteristic_values(raw.get("eyes"), CHARACTER_EYE_COLOR_CHOICES), + "softcore_outfits": _normalize_characteristic_values(raw.get("softcore_outfits"), None, allow_free_text=True), + "hardcore_clothing": _normalize_characteristic_values(raw.get("hardcore_clothing"), None, allow_free_text=True), + } + + +def _characteristics_summary(config: dict[str, Any]) -> str: + parts = [] + for key, label in ( + ("ages", "ages"), + ("bodies", "bodies"), + ("eyes", "eyes"), + ("softcore_outfits", "soft_outfits"), + ("hardcore_clothing", "hard_clothing"), + ): + values = config.get(key) or [] + if not values: + continue + if key in ("softcore_outfits", "hardcore_clothing"): + parts.append(f"{label}={len(values)}") + else: + parts.append(f"{label}={','.join(values)}") + return "; ".join(parts) if parts else "characteristics unrestricted" + + +def build_characteristics_config_json( + characteristics: str | dict[str, Any] | None = "", + axis: str = "ages", + selected_values: list[str] | tuple[str, ...] | str | None = None, + combine_mode: str = "replace_axis", +) -> str: + config = _parse_characteristics_config(characteristics) + axis_key = str(axis or "").strip().lower() + if axis_key not in config: + config["summary"] = _characteristics_summary(config) + return json.dumps(config, ensure_ascii=True, sort_keys=True) + choices = CHARACTER_CHARACTERISTIC_AXES.get(axis_key) + values = _normalize_characteristic_values( + selected_values, + choices, + allow_free_text=choices is None, + ) + if combine_mode == "add_to_axis": + existing = list(config.get(axis_key) or []) + for value in values: + if value not in existing: + existing.append(value) + config[axis_key] = existing + else: + config[axis_key] = values + config["summary"] = _characteristics_summary(config) + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + +def _characteristic_choice(config: dict[str, Any], key: str, rng: random.Random) -> str: + values = config.get(key) or [] + return g.choose(rng, values) if values else "" + + +def _eye_phrase_from_key(key: str) -> str: + return { + "blue": "blue eyes", + "pale_blue": "pale blue eyes", + "ice_blue": "ice blue eyes", + "blue_gray": "blue-gray eyes", + "green": "green eyes", + "emerald_green": "emerald green eyes", + "hazel": "hazel eyes", + "light_hazel": "light hazel eyes", + "green_hazel": "green-hazel eyes", + "amber": "amber eyes", + "amber_brown": "amber-brown eyes", + "honey_brown": "honey-brown eyes", + "brown": "brown eyes", + "deep_brown": "deep brown eyes", + "dark_brown": "dark brown eyes", + "dark": "dark eyes", + "gray": "gray eyes", + "gray_brown": "gray-brown eyes", + }.get(key, "") + + def _normalize_descriptor_detail(value: Any) -> str: text = str(value or "auto").strip() return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto" @@ -3143,6 +3344,11 @@ def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]: "skin": manual_fallback("skin"), "hair": manual_fallback("hair"), "manual": manual_config, + "characteristics": ( + slot.get("characteristics") + if isinstance(slot.get("characteristics"), dict) + else _slot_value(slot.get("characteristics") or slot.get("characteristics_config")) + ), "hair_config": ( slot.get("hair_config") if isinstance(slot.get("hair_config"), dict) @@ -3227,6 +3433,10 @@ def _character_slot_summary(slot: dict[str, Any]) -> str: parts.append(f"soft_outfit={slot['softcore_outfit']}") if slot.get("hardcore_clothing"): parts.append(f"hard_clothing={slot['hardcore_clothing']}") + characteristics = _parse_characteristics_config(slot.get("characteristics")) + characteristics_summary = _characteristics_summary(characteristics) + if characteristics_summary != "characteristics unrestricted": + parts.append(f"characteristics={characteristics_summary}") hair_config = _parse_hair_config(slot.get("hair_config")) hair_config_summary = _hair_config_summary(hair_config) if hair_config_summary != "hair unrestricted": @@ -3256,6 +3466,7 @@ def build_character_slot_json( body_phrase: str = "", skin: str = "", hair: str = "", + characteristics: str | dict[str, Any] | None = "", hair_config: str | dict[str, Any] | None = "", hair_color: str = "random", hair_length: str = "random", @@ -3288,6 +3499,7 @@ def build_character_slot_json( "body_phrase": body_phrase, "skin": skin, "hair": hair, + "characteristics": characteristics, "hair_config": hair_config, "hair_color": hair_color, "hair_length": hair_length, @@ -3423,12 +3635,26 @@ def _body_exposure_scene_text(scene: Any) -> str: return _clean_prompt_punctuation(text) -def _slot_softcore_outfit(slot: dict[str, Any] | None) -> str: - return _slot_value(slot.get("softcore_outfit")) if slot else "" +def _slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: + if not slot: + return "" + outfit = _slot_value(slot.get("softcore_outfit")) + if outfit: + return outfit + if rng is None: + return "" + return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "softcore_outfits", rng) -def _slot_hardcore_clothing(slot: dict[str, Any] | None) -> str: - return _slot_value(slot.get("hardcore_clothing")) if slot else "" +def _slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: + if not slot: + return "" + clothing = _slot_value(slot.get("hardcore_clothing")) + if clothing: + return clothing + if rng is None: + return "" + return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "hardcore_clothing", rng) def _softcore_outfit_sentence(label: str, outfit: str) -> str: @@ -3460,6 +3686,7 @@ def _character_hardcore_clothing_entries( 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 = [ @@ -3470,7 +3697,7 @@ def _character_hardcore_clothing_entries( for label in labels: if label in pov_set: continue - clothing = _slot_hardcore_clothing(label_map.get(label)) + clothing = _slot_hardcore_clothing(label_map.get(label), rng) sentence = _hardcore_clothing_sentence(label, clothing) if sentence: entries.append(sentence) @@ -3502,8 +3729,11 @@ def _context_from_character_slot( effective_no_black, ) - age = _slot_value(slot.get("age")) + characteristics = _parse_characteristics_config(slot.get("characteristics")) + age = _slot_value(slot.get("age")) or _characteristic_choice(characteristics, "ages", appearance_rng) body_phrase = _slot_value(slot.get("body_phrase")) + if not slot_body: + slot_body = _characteristic_choice(characteristics, "bodies", appearance_rng) if age: context["age"] = age if slot_body: @@ -3514,10 +3744,14 @@ def _context_from_character_slot( context["body_phrase"] = f"{slot_body} figure" if body_phrase: context["body_phrase"] = body_phrase - for key in ("skin", "eyes"): - value = _slot_value(slot.get(key)) - if value: - context[key] = value + skin_value = _slot_value(slot.get("skin")) + if skin_value: + context["skin"] = skin_value + eyes_value = _slot_value(slot.get("eyes")) + if not eyes_value: + eyes_value = _eye_phrase_from_key(_characteristic_choice(characteristics, "eyes", appearance_rng)) + if eyes_value: + context["eyes"] = eyes_value hair_value = _slot_value(slot.get("hair")) if hair_value: context["hair"] = hair_value @@ -5255,6 +5489,34 @@ 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 [] + + +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 sexual contact clearly visible"] + if state == "partially_removed": + return ["teaser outfit is pushed aside and partly removed, exposing the sexual contact clearly"] + if state == "custom": + return _normalize_characteristic_values(custom_clothing, None, allow_free_text=True) + return [] + + def build_insta_of_options_json( softcore_cast: str = "solo", hardcore_cast: str = "use_counts", @@ -5534,7 +5796,7 @@ def _insta_of_partner_styling( for index in range(max(0, women_count - 1)): label = chr(ord("B") + index) full_label = f"Woman {label}" - outfit = _slot_softcore_outfit((label_map or {}).get(full_label)) or g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS) + 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) if sentence: outfits.append(sentence) @@ -5543,7 +5805,7 @@ def _insta_of_partner_styling( full_label = f"Man {label}" if full_label in pov_set: continue - outfit = _slot_softcore_outfit((label_map or {}).get(full_label)) or g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS) + 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) if sentence: outfits.append(sentence) @@ -5594,6 +5856,7 @@ def build_insta_of_pair( softcore_level_key = str(options["softcore_level"]) soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key) soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311) + hard_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 317) soft_person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number) soft_expression_women_count = hard_women_count if options["softcore_cast"] == "same_as_hardcore" else 1 soft_expression_men_count = hard_men_count if options["softcore_cast"] == "same_as_hardcore" else 0 @@ -5659,7 +5922,7 @@ def build_insta_of_pair( soft_row["character_slot_status"] = "applied:Woman A" if not soft_expression_enabled: soft_row = _disable_row_expression(soft_row, soft_expression_intensity_source) - primary_softcore_outfit = _slot_softcore_outfit(primary_slot) + primary_softcore_outfit = _slot_softcore_outfit(primary_slot, soft_content_rng) soft_row["item"] = primary_softcore_outfit or _insta_of_softcore_outfit(soft_content_rng, softcore_level_key) soft_row["pose"] = _insta_of_softcore_pose(soft_content_rng, softcore_level_key) soft_row["item_label"] = "Insta/OF softcore body exposure" if softcore_level_key == "explicit_nude" else "Insta/OF softcore outfit" @@ -5791,6 +6054,7 @@ def build_insta_of_pair( hard_women_count, hard_men_count, pov_character_labels, + hard_content_rng, ) has_primary_hardcore_clothing = any(entry.startswith("Woman A") for entry in character_hardcore_clothing_entries) fallback_hard_clothing_state = "" if has_primary_hardcore_clothing else _insta_of_hardcore_clothing_state(