From 8ecb1a65c5fcfd090eaf3ba25845939d6c30f405 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 25 Jun 2026 01:05:58 +0200 Subject: [PATCH] Add hardcore position control nodes --- __init__.py | 107 +++++++++++ prompt_builder.py | 444 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 550 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index b6c5cf5..a75f57e 100644 --- a/__init__.py +++ b/__init__.py @@ -21,6 +21,7 @@ SXCP_CATEGORY_CONFIG = "SXCP_CATEGORY_CONFIG" SXCP_CAST_CONFIG = "SXCP_CAST_CONFIG" SXCP_GENERATION_PROFILE = "SXCP_GENERATION_PROFILE" SXCP_INSTA_OF_OPTIONS = "SXCP_INSTA_OF_OPTIONS" +SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG" SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST" SXCP_CHARACTER_SLOT = "SXCP_CHARACTER_SLOT" SXCP_CHARACTER_PROFILE = "SXCP_CHARACTER_PROFILE" @@ -41,6 +42,8 @@ try: build_filter_config_json, build_generation_profile_json, build_hair_config_json, + build_hardcore_action_filter_json, + build_hardcore_position_pool_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, @@ -81,6 +84,9 @@ try: character_woman_body_choices, ethnicity_choices, generation_profile_choices, + hardcore_position_family_choices, + hardcore_position_focus_choices, + hardcore_position_key_choices, hardcore_detail_density_choices, load_character_profile_json, save_character_profile_payload, @@ -105,6 +111,8 @@ except ImportError: build_filter_config_json, build_generation_profile_json, build_hair_config_json, + build_hardcore_action_filter_json, + build_hardcore_position_pool_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, @@ -145,6 +153,9 @@ except ImportError: character_woman_body_choices, ethnicity_choices, generation_profile_choices, + hardcore_position_family_choices, + hardcore_position_focus_choices, + hardcore_position_key_choices, hardcore_detail_density_choices, load_character_profile_json, save_character_profile_payload, @@ -199,6 +210,7 @@ class SxCPPromptBuilder: "camera_config": (SXCP_CAMERA_CONFIG,), "character_profile": (SXCP_CHARACTER_PROFILE,), "character_cast": (SXCP_CHARACTER_CAST,), + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -233,6 +245,7 @@ class SxCPPromptBuilder: camera_config="", character_profile="", character_cast="", + hardcore_position_config="", extra_positive="", extra_negative="", no_plus_women=False, @@ -266,6 +279,7 @@ class SxCPPromptBuilder: camera_config=camera_config or "", character_profile=character_profile or "", character_cast=character_cast or "", + hardcore_position_config=hardcore_position_config or "", ) return ( row["prompt"], @@ -1077,6 +1091,89 @@ class SxCPCharacterClothing: return config, json.loads(config).get("summary", "") +class SxCPHardcorePositionPool: + @classmethod + def INPUT_TYPES(cls): + required = { + "combine_mode": (["replace", "add"], {"default": "replace"}), + "family": (hardcore_position_family_choices(), {"default": "any"}), + } + for choice in hardcore_position_key_choices(): + required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False}) + return { + "required": required, + "optional": { + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") + RETURN_NAMES = ("hardcore_position_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, combine_mode="replace", family="any", hardcore_position_config="", **kwargs): + selected = [ + choice + for choice in hardcore_position_key_choices() + if bool(kwargs.get(_choice_input_key("include", choice), False)) + ] + config = build_hardcore_position_pool_json( + hardcore_position_config=hardcore_position_config or "", + combine_mode=combine_mode, + family=family, + selected_positions=selected, + ) + return config, json.loads(config).get("summary", "") + + +class SxCPHardcoreActionFilter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "focus": (hardcore_position_focus_choices(), {"default": "keep_pool"}), + "allow_toys": ("BOOLEAN", {"default": False}), + "allow_double": ("BOOLEAN", {"default": False}), + "allow_penetration": ("BOOLEAN", {"default": True}), + "allow_oral": ("BOOLEAN", {"default": True}), + "allow_anal": ("BOOLEAN", {"default": True}), + "allow_climax": ("BOOLEAN", {"default": True}), + }, + "optional": { + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") + RETURN_NAMES = ("hardcore_position_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + focus, + allow_toys, + allow_double, + allow_penetration, + allow_oral, + allow_anal, + allow_climax, + hardcore_position_config="", + ): + config = build_hardcore_action_filter_json( + hardcore_position_config=hardcore_position_config or "", + focus=focus, + allow_toys=allow_toys, + allow_double=allow_double, + allow_penetration=allow_penetration, + allow_oral=allow_oral, + allow_anal=allow_anal, + allow_climax=allow_climax, + ) + return config, json.loads(config).get("summary", "") + + class SxCPCharacterManualDetails: @classmethod def INPUT_TYPES(cls): @@ -1150,6 +1247,7 @@ class SxCPPromptBuilderFromConfigs: "camera_config": (SXCP_CAMERA_CONFIG,), "character_profile": (SXCP_CHARACTER_PROFILE,), "character_cast": (SXCP_CHARACTER_CAST,), + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -1174,6 +1272,7 @@ class SxCPPromptBuilderFromConfigs: camera_config="", character_profile="", character_cast="", + hardcore_position_config="", extra_positive="", extra_negative="", ): @@ -1189,6 +1288,7 @@ class SxCPPromptBuilderFromConfigs: camera_config=camera_config or "", character_profile=character_profile or "", character_cast=character_cast or "", + hardcore_position_config=hardcore_position_config or "", extra_positive=extra_positive or "", extra_negative=extra_negative or "", ) @@ -1818,6 +1918,7 @@ class SxCPInstaOFPromptPair: "hardcore_camera_config": (SXCP_CAMERA_CONFIG,), "character_profile": (SXCP_CHARACTER_PROFILE,), "character_cast": (SXCP_CHARACTER_CAST,), + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -1855,6 +1956,7 @@ class SxCPInstaOFPromptPair: hardcore_camera_config="", character_profile="", character_cast="", + hardcore_position_config="", extra_positive="", extra_negative="", no_plus_women=False, @@ -1878,6 +1980,7 @@ class SxCPInstaOFPromptPair: hardcore_camera_config=hardcore_camera_config or "", character_profile=character_profile or "", character_cast=character_cast or "", + hardcore_position_config=hardcore_position_config or "", extra_positive=extra_positive or "", extra_negative=extra_negative or "", ) @@ -1914,6 +2017,8 @@ NODE_CLASS_MAPPINGS = { "SxCPManBodyPool": SxCPManBodyPool, "SxCPEyeColorPool": SxCPEyeColorPool, "SxCPCharacterClothing": SxCPCharacterClothing, + "SxCPHardcorePositionPool": SxCPHardcorePositionPool, + "SxCPHardcoreActionFilter": SxCPHardcoreActionFilter, "SxCPCharacterManualDetails": SxCPCharacterManualDetails, "SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, @@ -1950,6 +2055,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPManBodyPool": "SxCP Man Body Pool", "SxCPEyeColorPool": "SxCP Eye Color Pool", "SxCPCharacterClothing": "SxCP Character Clothing", + "SxCPHardcorePositionPool": "SxCP Hardcore Position Pool", + "SxCPHardcoreActionFilter": "SxCP Hardcore Action Filter", "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 d76b446..dc8a66d 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -295,6 +295,77 @@ CHARACTER_EYE_COLOR_CHOICES = [ CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] +HARDCORE_POSITION_FAMILY_CHOICES = [ + "any", + "penetrative", + "oral", + "anal", + "climax", + "threesome", + "group", +] +HARDCORE_POSITION_FOCUS_CHOICES = [ + "keep_pool", + "penetration_only", + "oral_only", + "anal_only", + "climax_only", + "threesome_only", + "group_only", +] +HARDCORE_POSITION_KEY_CHOICES = [ + "missionary", + "cowgirl", + "reverse_cowgirl", + "doggy", + "bent_over", + "face_down_ass_up", + "standing", + "side_lying", + "edge_supported", + "kneeling", + "lotus_lap", + "face_sitting", + "sixty_nine", + "spread_leg_oral", + "open_thighs", + "front_back", +] +HARDCORE_POSITION_FAMILY_SUBCATEGORIES = { + "any": [ + "penetrative_sex", + "oral_sex", + "anal_double_penetration", + "threesomes", + "group_sex_orgy", + "cumshot_climax", + ], + "penetrative": ["penetrative_sex"], + "oral": ["oral_sex"], + "anal": ["anal_double_penetration"], + "climax": ["cumshot_climax"], + "threesome": ["threesomes"], + "group": ["group_sex_orgy"], +} +HARDCORE_POSITION_KEY_MATCHES = { + "missionary": ("missionary", "above her", "under her"), + "cowgirl": ("cowgirl", "straddling", "straddles", "on top", "squatting on top"), + "reverse_cowgirl": ("reverse cowgirl", "facing away"), + "doggy": ("doggy", "all fours", "rear-entry", "from behind"), + "bent_over": ("bent-over", "bent over", "hips raised"), + "face_down_ass_up": ("face-down", "ass-up"), + "standing": ("standing", "stands", "braced standing"), + "side_lying": ("side-lying", "side lying", "spooning", "on the side", "on her side"), + "edge_supported": ("edge-of-bed", "edge of bed", "bed edge", "raised edge", "edge-supported"), + "kneeling": ("kneeling", "kneels", "kneeling center"), + "lotus_lap": ("lotus", "lap", "seated in a partner's lap"), + "face_sitting": ("face-sitting", "face sitting"), + "sixty_nine": ("sixty-nine", "69"), + "spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"), + "open_thighs": ("thighs open", "legs spread", "open thighs", "legs open", "reclining with thighs open"), + "front_back": ("front-and-back", "front and back", "one behind and one in front", "between two partners"), +} +HARDCORE_POSITION_AXIS_KEYS = {"position", "body_position", "body_arrangement", "arrangement"} CAMERA_ORBIT_FRAMING_CHOICES = [ "from_zoom", "wide", @@ -1517,6 +1588,319 @@ def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str return parsed +def _normalize_hardcore_position_family(value: Any, default: str = "any") -> str: + text = str(value or default).strip() + return text if text in HARDCORE_POSITION_FAMILY_CHOICES else default + + +def _normalize_hardcore_position_values(values: Any) -> list[str]: + raw_values = _list_from(values) + selected: list[str] = [] + for value in raw_values: + text = str(value or "").strip() + if not text or text == "any": + continue + normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_") + if normalized in HARDCORE_POSITION_KEY_CHOICES and normalized not in selected: + selected.append(normalized) + return selected + + +def _empty_hardcore_position_config() -> dict[str, Any]: + return { + "config_type": "hardcore_position", + "enabled": False, + "family": "any", + "positions": [], + "allow_toys": True, + "allow_double": True, + "allow_penetration": True, + "allow_oral": True, + "allow_anal": True, + "allow_climax": True, + } + + +def _parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[str, Any]: + if not value: + return _empty_hardcore_position_config() + if isinstance(value, dict): + raw = value + else: + try: + raw = json.loads(str(value)) + except json.JSONDecodeError: + return _empty_hardcore_position_config() + if not isinstance(raw, dict): + return _empty_hardcore_position_config() + parsed = {**_empty_hardcore_position_config(), **raw} + parsed["enabled"] = bool(parsed.get("enabled", True)) + parsed["family"] = _normalize_hardcore_position_family(parsed.get("family")) + parsed["positions"] = _normalize_hardcore_position_values(parsed.get("positions")) + for key in ("allow_toys", "allow_double", "allow_penetration", "allow_oral", "allow_anal", "allow_climax"): + parsed[key] = not _is_false(parsed.get(key, True)) + return parsed + + +def _hardcore_position_summary(config: dict[str, Any]) -> str: + if not config.get("enabled"): + return "hardcore position unrestricted" + parts = [f"family={config.get('family', 'any')}"] + positions = config.get("positions") or [] + if positions: + parts.append("positions=" + ",".join(positions)) + disabled = [ + label + for key, label in ( + ("allow_toys", "toys"), + ("allow_double", "double"), + ("allow_penetration", "penetration"), + ("allow_oral", "oral"), + ("allow_anal", "anal"), + ("allow_climax", "climax"), + ) + if not config.get(key, True) + ] + if disabled: + parts.append("blocked=" + ",".join(disabled)) + return "; ".join(parts) + + +def build_hardcore_position_pool_json( + hardcore_position_config: str | dict[str, Any] | None = "", + combine_mode: str = "replace", + family: str = "any", + selected_positions: list[str] | tuple[str, ...] | str | None = None, +) -> str: + base = _parse_hardcore_position_config(hardcore_position_config) + if combine_mode == "replace": + base = {**_empty_hardcore_position_config(), "enabled": True} + else: + base["enabled"] = True + base["family"] = _normalize_hardcore_position_family(family, base.get("family", "any")) + selected = _normalize_hardcore_position_values(selected_positions) + if combine_mode == "add": + existing = list(base.get("positions") or []) + for value in selected: + if value not in existing: + existing.append(value) + base["positions"] = existing + else: + base["positions"] = selected + base["summary"] = _hardcore_position_summary(base) + return json.dumps(base, ensure_ascii=True, sort_keys=True) + + +def build_hardcore_action_filter_json( + hardcore_position_config: str | dict[str, Any] | None = "", + focus: str = "keep_pool", + allow_toys: bool = False, + allow_double: bool = False, + allow_penetration: bool = True, + allow_oral: bool = True, + allow_anal: bool = True, + allow_climax: bool = True, +) -> str: + config = _parse_hardcore_position_config(hardcore_position_config) + config["enabled"] = True + focus = str(focus or "keep_pool").strip() + focus_family = { + "penetration_only": "penetrative", + "oral_only": "oral", + "anal_only": "anal", + "climax_only": "climax", + "threesome_only": "threesome", + "group_only": "group", + }.get(focus) + if focus_family: + config["family"] = focus_family + config["allow_toys"] = bool(allow_toys) + config["allow_double"] = bool(allow_double) + config["allow_penetration"] = bool(allow_penetration) + config["allow_oral"] = bool(allow_oral) + config["allow_anal"] = bool(allow_anal) + config["allow_climax"] = bool(allow_climax) + if config["family"] == "oral": + config["allow_oral"] = True + config["allow_penetration"] = False + elif config["family"] == "anal": + config["allow_anal"] = True + config["allow_penetration"] = True + elif config["family"] == "climax": + config["allow_climax"] = True + config["summary"] = _hardcore_position_summary(config) + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + +def _hardcore_position_config_active(config: dict[str, Any]) -> bool: + return bool(config.get("enabled")) + + +def _is_hardcore_sexual_category(category: dict[str, Any]) -> bool: + return str(category.get("slug") or "").strip() == "hardcore_sexual_poses" or str(category.get("name") or "").strip().lower() == "hardcore sexual poses" + + +def _hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]: + family = _normalize_hardcore_position_family(config.get("family")) + allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])) + if not config.get("allow_penetration", True): + allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"}) + if not config.get("allow_oral", True): + allowed.discard("oral_sex") + if not config.get("allow_anal", True): + allowed.discard("anal_double_penetration") + if not config.get("allow_climax", True): + allowed.discard("cumshot_climax") + if not config.get("allow_double", True) and family == "anal": + allowed.add("anal_double_penetration") + return allowed or set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]) + + +def _filter_hardcore_categories_for_position( + categories: list[dict[str, Any]], + config: dict[str, Any], + women_count: int, + men_count: int, +) -> list[dict[str, Any]]: + if not _hardcore_position_config_active(config): + return categories + allowed = _hardcore_allowed_subcategory_slugs(config) + filtered_categories: list[dict[str, Any]] = [] + for category in categories: + if not _is_hardcore_sexual_category(category): + filtered_categories.append(category) + continue + category_copy = dict(category) + subcategories = [ + subcategory + for subcategory in category.get("subcategories", []) + if str(subcategory.get("slug") or "") in allowed and _compatible_entry(subcategory, women_count, men_count) + and _hardcore_subcategory_supports_positions(subcategory, config) + ] + if subcategories: + category_copy["subcategories"] = subcategories + filtered_categories.append(category_copy) + return filtered_categories + + +def _hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str, Any]) -> bool: + text = str(text or "").lower() + axis_name = str(axis_name or "").lower() + if not config.get("allow_toys", True) and any(term in text for term in ("toy", "dildo", "strap-on", "strap on")): + return True + if not config.get("allow_double", True) and ( + axis_name == "double_act" + or any(term in text for term in ("double penetration", "double-penetration", "front-and-back", "front and back", "second penetration", "both sides", "two partners penetrating", "multiple penetrations")) + ): + return True + if not config.get("allow_anal", True) and ( + axis_name == "anal_act" + or any(term in text for term in (" anal", "ass", "rear-entry anal")) + ): + return True + if not config.get("allow_oral", True) and ( + axis_name in ("oral_act", "oral_detail") + or any(term in text for term in ("oral sex", "mouth on genitals", "mouth on pussy", "blowjob", "cunnilingus", "tongue on pussy", "deepthroat", "fellatio")) + ): + return True + if not config.get("allow_penetration", True) and ( + axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail") + or any(term in text for term in ("penetration", "penetrative", "thrust", "penis entering", "vaginal sex", "anal sex")) + ): + return True + if not config.get("allow_climax", True) and ( + axis_name in ("climax_act", "climax_hint", "climax_detail", "fluid_detail", "fluid_location") + or any(term in text for term in ("climax", "cum", "semen", "ejaculat", "creampie", "post-orgasm", "post-penetration")) + ): + return True + return False + + +def _hardcore_position_entry_matches(entry: Any, config: dict[str, Any]) -> bool: + positions = config.get("positions") or [] + if not positions: + return True + text = _entry_text(entry).lower() + for position in positions: + if any(term in text for term in HARDCORE_POSITION_KEY_MATCHES.get(position, ())): + return True + return False + + +def _hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> bool: + selected = set(config.get("positions") or []) + if not selected: + return False + text = _entry_text(entry).lower() + matched = { + position + for position, terms in HARDCORE_POSITION_KEY_MATCHES.items() + if any(term in text for term in terms) + } + return bool(matched) and not bool(matched & selected) + + +def _hardcore_subcategory_supports_positions(subcategory: dict[str, Any], config: dict[str, Any]) -> bool: + if not config.get("positions"): + return True + axes = subcategory.get("item_axes") + if not isinstance(axes, dict): + return True + for axis_name, values in axes.items(): + if str(axis_name) in HARDCORE_POSITION_AXIS_KEYS and any( + _hardcore_position_entry_matches(value, config) + for value in _list_from(values) + ): + return True + return False + + +def _filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, Any]) -> list[Any]: + if not _hardcore_position_config_active(config): + return values + filtered = [ + value + for value in values + if not _hardcore_text_blocked_by_action(_entry_text(value), axis_name, config) + and not (axis_name not in HARDCORE_POSITION_AXIS_KEYS and _hardcore_position_entry_conflicts(value, config)) + and (axis_name not in HARDCORE_POSITION_AXIS_KEYS or _hardcore_position_entry_matches(value, config)) + ] + return filtered or values + + +def _filter_hardcore_templates(templates: list[Any], config: dict[str, Any]) -> list[Any]: + if not _hardcore_position_config_active(config): + return templates + filtered: list[Any] = [] + for template in templates: + text = _entry_text(template) + fields = {key for _, key, _, _ in Formatter().parse(text) if key} + blocked = bool(config.get("positions")) and not bool(fields & HARDCORE_POSITION_AXIS_KEYS) + blocked = blocked or any(_hardcore_text_blocked_by_action(text, field, config) for field in fields | {""}) + if not blocked: + filtered.append(template) + return filtered or templates + + +def _apply_hardcore_position_config_to_subcategory( + subcategory: dict[str, Any], + config: dict[str, Any], +) -> dict[str, Any]: + if not _hardcore_position_config_active(config): + return subcategory + subcategory_copy = dict(subcategory) + if "item_templates" in subcategory_copy: + subcategory_copy["item_templates"] = _filter_hardcore_templates(_list_from(subcategory_copy["item_templates"]), config) + raw_axes = subcategory_copy.get("item_axes") + if isinstance(raw_axes, dict): + axes = {} + for axis_name, values in raw_axes.items(): + axes[axis_name] = _filter_hardcore_axis(str(axis_name), _list_from(values), config) + subcategory_copy["item_axes"] = axes + subcategory_copy["hardcore_position_config"] = config + return subcategory_copy + + def _ratio_or_none(value: float) -> float | None: try: ratio = float(value) @@ -1802,6 +2186,18 @@ def hardcore_detail_density_choices() -> list[str]: return list(HARDCORE_DETAIL_DENSITY_CHOICES) +def hardcore_position_family_choices() -> list[str]: + return list(HARDCORE_POSITION_FAMILY_CHOICES) + + +def hardcore_position_focus_choices() -> list[str]: + return list(HARDCORE_POSITION_FOCUS_CHOICES) + + +def hardcore_position_key_choices() -> list[str]: + return list(HARDCORE_POSITION_KEY_CHOICES) + + def character_softcore_outfit_source_choices() -> list[str]: return [ "no_change", @@ -4461,6 +4857,30 @@ def _role_graph( return f"{woman} sits in {man}'s lap facing him with legs around his hips while {man}'s penis thrusts into her." return f"{woman} lies on her back with thighs open while {man} kneels between her legs and {man}'s penis thrusts into her." + def anal_position_graph(woman: str, man: str) -> str: + text = " ".join( + str(part or "").lower() + for part in ( + item_text, + *((item_axis_values or {}).values()), + ) + ) + if "bent-over" in text or "bent over" in text: + return f"{woman} is bent forward with hips raised while {man} stands behind her and thrusts his penis into her ass." + if "face-down" in text: + return f"{woman} lies face-down with ass raised while {man} is positioned behind her and thrusts his penis into her ass." + if "doggy" in text or "rear-entry" in text: + return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass." + if "standing" in text: + return f"{woman} stands braced with hips angled back while {man} stands behind her and thrusts his penis into her ass." + if "spooning" in text or "side-lying" in text: + return f"{woman} lies on her side with thighs parted while {man} presses behind her and thrusts his penis into her ass." + if "edge-of-bed" in text or "edge of bed" in text or "bed edge" in text: + return f"{woman} lies near a raised edge with hips exposed while {man} kneels behind her and thrusts his penis into her ass." + if "kneeling" in text: + return f"{woman} kneels forward with hips raised while {man} kneels behind her and thrusts his penis into her ass." + return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass." + if people_count == 1: solo = people[0] if women_count == 1: @@ -4546,7 +4966,7 @@ def _role_graph( elif people_count >= 3: graph = f"{man} thrusts his penis into {woman} while {third} gives oral contact from the front." else: - graph = f"{man} thrusts his penis into {woman}'s ass while keeping her hips held open." + graph = anal_position_graph(woman, man) elif "threesome" in slug: graph = f"{man} thrusts his penis into {woman} while {third or any_person({woman, man})} uses mouth and hands on the exposed body." elif "group" in slug or "orgy" in slug: @@ -4871,6 +5291,7 @@ def _build_custom_row( character_profile: str | dict[str, Any] | None = None, character_cast: str | dict[str, Any] | list[Any] | None = None, expression_phase: str = "", + hardcore_position_config: str | dict[str, Any] | None = None, ) -> dict[str, Any]: categories = load_category_library() category_rng = _axis_rng(seed_config, "category", seed, row_number) @@ -4881,9 +5302,16 @@ def _build_custom_row( role_rng = _axis_rng(seed_config, "role", seed, row_number) expression_rng = _axis_rng(seed_config, "expression", seed, row_number) composition_rng = _axis_rng(seed_config, "composition", seed, row_number) + parsed_hardcore_position_config = _parse_hardcore_position_config(hardcore_position_config) requested_women_count = women_count requested_men_count = men_count + categories = _filter_hardcore_categories_for_position( + categories, + parsed_hardcore_position_config, + women_count, + men_count, + ) category, subcategory, women_count, men_count = _find_subcategory( categories, category_choice, @@ -4901,6 +5329,8 @@ def _build_custom_row( "effective_women_count": women_count, "effective_men_count": men_count, } + if _is_hardcore_sexual_category(category): + subcategory = _apply_hardcore_position_config_to_subcategory(subcategory, parsed_hardcore_position_config) content_axis = "pose" if _is_pose_content_category(category, subcategory) else "content" content_rng = _axis_rng(seed_config, content_axis, seed, row_number) items = _list_from(subcategory.get("items", [subcategory["name"]])) @@ -5133,6 +5563,11 @@ def _build_custom_row( "scene_text": scene, "pose": pose, "seed_config": seed_config, + "hardcore_position_config": ( + parsed_hardcore_position_config + if _hardcore_position_config_active(parsed_hardcore_position_config) + else {} + ), "content_seed_axis": content_axis, "role_graph": role_graph, "source_role_graph": source_role_graph, @@ -5197,6 +5632,7 @@ def build_prompt( character_cast: str | dict[str, Any] | list[Any] | None = None, expression_enabled: bool = True, expression_phase: str = "", + hardcore_position_config: str | dict[str, Any] | None = None, ) -> dict[str, Any]: apply_pool_extensions() row_number = max(1, int(row_number)) @@ -5265,6 +5701,7 @@ def build_prompt( character_profile, character_cast, expression_phase, + hardcore_position_config, ) if not expression_enabled: @@ -5293,6 +5730,7 @@ def build_prompt_from_configs( camera_config: str | dict[str, Any] | None = "", character_profile: str | dict[str, Any] | None = "", character_cast: str | dict[str, Any] | list[Any] | None = "", + hardcore_position_config: str | dict[str, Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -5327,6 +5765,7 @@ def build_prompt_from_configs( camera_config=camera_config or "", character_profile=character_profile or "", character_cast=character_cast or "", + hardcore_position_config=hardcore_position_config or "", ) @@ -5837,6 +6276,7 @@ def build_insta_of_pair( hardcore_camera_config: str | dict[str, Any] | None = None, character_profile: str | dict[str, Any] | None = "", character_cast: str | dict[str, Any] | list[Any] | None = "", + hardcore_position_config: str | dict[str, Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -5970,6 +6410,7 @@ def build_insta_of_pair( expression_intensity=options["hardcore_expression_intensity"], character_cast=character_cast or "", expression_phase="hardcore", + hardcore_position_config=hardcore_position_config or "", ) hard_row["hardcore_detail_density"] = options["hardcore_detail_density"] hard_row["pov_character_labels"] = pov_character_labels @@ -6154,6 +6595,7 @@ def build_insta_of_pair( "character_hardcore_clothing": character_hardcore_clothing_entries, "hardcore_clothing_state": hard_clothing_state, "hardcore_detail_density": hard_detail_density, + "hardcore_position_config": hard_row.get("hardcore_position_config", {}), "softcore_prompt": soft_prompt, "hardcore_prompt": hard_prompt, "softcore_negative_prompt": soft_negative,