Add chainable character characteristics nodes

This commit is contained in:
2026-06-25 00:50:28 +02:00
parent 13a1a7c962
commit 91d5049774
2 changed files with 495 additions and 13 deletions
+218
View File
@@ -11,6 +11,7 @@ except Exception:
PromptServer = None PromptServer = None
SXCP_HAIR_CONFIG = "SXCP_HAIR_CONFIG" SXCP_HAIR_CONFIG = "SXCP_HAIR_CONFIG"
SXCP_CHARACTERISTICS = "SXCP_CHARACTERISTICS"
SXCP_CHARACTER_MANUAL = "SXCP_CHARACTER_MANUAL" SXCP_CHARACTER_MANUAL = "SXCP_CHARACTER_MANUAL"
SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST" SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST"
SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG" SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG"
@@ -35,6 +36,7 @@ try:
build_character_slot_json, build_character_slot_json,
build_character_manual_config_json, build_character_manual_config_json,
build_character_profile_json, build_character_profile_json,
build_characteristics_config_json,
build_ethnicity_list_json, build_ethnicity_list_json,
build_filter_config_json, build_filter_config_json,
build_generation_profile_json, build_generation_profile_json,
@@ -63,6 +65,7 @@ try:
character_body_choices, character_body_choices,
character_descriptor_detail_choices, character_descriptor_detail_choices,
character_ethnicity_choices, character_ethnicity_choices,
character_eye_color_choices,
character_figure_choices, character_figure_choices,
character_hair_color_choices, character_hair_color_choices,
character_hair_length_choices, character_hair_length_choices,
@@ -71,6 +74,10 @@ try:
character_man_body_choices, character_man_body_choices,
character_presence_choices, character_presence_choices,
character_profile_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, character_woman_body_choices,
ethnicity_choices, ethnicity_choices,
generation_profile_choices, generation_profile_choices,
@@ -93,6 +100,7 @@ except ImportError:
build_character_slot_json, build_character_slot_json,
build_character_manual_config_json, build_character_manual_config_json,
build_character_profile_json, build_character_profile_json,
build_characteristics_config_json,
build_ethnicity_list_json, build_ethnicity_list_json,
build_filter_config_json, build_filter_config_json,
build_generation_profile_json, build_generation_profile_json,
@@ -121,6 +129,7 @@ except ImportError:
character_body_choices, character_body_choices,
character_descriptor_detail_choices, character_descriptor_detail_choices,
character_ethnicity_choices, character_ethnicity_choices,
character_eye_color_choices,
character_figure_choices, character_figure_choices,
character_hair_color_choices, character_hair_color_choices,
character_hair_length_choices, character_hair_length_choices,
@@ -129,6 +138,10 @@ except ImportError:
character_man_body_choices, character_man_body_choices,
character_presence_choices, character_presence_choices,
character_profile_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, character_woman_body_choices,
ethnicity_choices, ethnicity_choices,
generation_profile_choices, generation_profile_choices,
@@ -880,6 +893,190 @@ class SxCPHairStyle(_SxCPHairAxisNode):
AXIS = "style" 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: class SxCPCharacterManualDetails:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -1028,6 +1225,7 @@ class SxCPCharacterSlot:
"optional": { "optional": {
"manual": (SXCP_CHARACTER_MANUAL,), "manual": (SXCP_CHARACTER_MANUAL,),
"ethnicity_list": (SXCP_ETHNICITY_LIST,), "ethnicity_list": (SXCP_ETHNICITY_LIST,),
"characteristics": (SXCP_CHARACTERISTICS,),
"hair_config": (SXCP_HAIR_CONFIG,), "hair_config": (SXCP_HAIR_CONFIG,),
"character_cast": (SXCP_CHARACTER_CAST,), "character_cast": (SXCP_CHARACTER_CAST,),
}, },
@@ -1056,6 +1254,7 @@ class SxCPCharacterSlot:
hardcore_expression_intensity=-1.0, hardcore_expression_intensity=-1.0,
character_cast="", character_cast="",
ethnicity_list="", ethnicity_list="",
characteristics="",
hair_config="", hair_config="",
manual="", manual="",
): ):
@@ -1072,6 +1271,7 @@ class SxCPCharacterSlot:
body_phrase="", body_phrase="",
skin="", skin="",
hair="", hair="",
characteristics=characteristics,
hair_config=hair_config, hair_config=hair_config,
eyes="", eyes="",
descriptor_detail=descriptor_detail, descriptor_detail=descriptor_detail,
@@ -1109,6 +1309,7 @@ class SxCPWomanSlot:
"optional": { "optional": {
"manual": (SXCP_CHARACTER_MANUAL,), "manual": (SXCP_CHARACTER_MANUAL,),
"ethnicity_list": (SXCP_ETHNICITY_LIST,), "ethnicity_list": (SXCP_ETHNICITY_LIST,),
"characteristics": (SXCP_CHARACTERISTICS,),
"hair_config": (SXCP_HAIR_CONFIG,), "hair_config": (SXCP_HAIR_CONFIG,),
"character_cast": (SXCP_CHARACTER_CAST,), "character_cast": (SXCP_CHARACTER_CAST,),
}, },
@@ -1135,6 +1336,7 @@ class SxCPWomanSlot:
hardcore_expression_intensity=-1.0, hardcore_expression_intensity=-1.0,
character_cast="", character_cast="",
ethnicity_list="", ethnicity_list="",
characteristics="",
hair_config="", hair_config="",
manual="", manual="",
): ):
@@ -1151,6 +1353,7 @@ class SxCPWomanSlot:
body_phrase="", body_phrase="",
skin="", skin="",
hair="", hair="",
characteristics=characteristics,
hair_config=hair_config, hair_config=hair_config,
eyes="", eyes="",
descriptor_detail=descriptor_detail, descriptor_detail=descriptor_detail,
@@ -1187,6 +1390,7 @@ class SxCPManSlot:
"optional": { "optional": {
"manual": (SXCP_CHARACTER_MANUAL,), "manual": (SXCP_CHARACTER_MANUAL,),
"ethnicity_list": (SXCP_ETHNICITY_LIST,), "ethnicity_list": (SXCP_ETHNICITY_LIST,),
"characteristics": (SXCP_CHARACTERISTICS,),
"hair_config": (SXCP_HAIR_CONFIG,), "hair_config": (SXCP_HAIR_CONFIG,),
"character_cast": (SXCP_CHARACTER_CAST,), "character_cast": (SXCP_CHARACTER_CAST,),
}, },
@@ -1213,6 +1417,7 @@ class SxCPManSlot:
hardcore_expression_intensity=-1.0, hardcore_expression_intensity=-1.0,
character_cast="", character_cast="",
ethnicity_list="", ethnicity_list="",
characteristics="",
hair_config="", hair_config="",
manual="", manual="",
): ):
@@ -1229,6 +1434,7 @@ class SxCPManSlot:
body_phrase="", body_phrase="",
skin="", skin="",
hair="", hair="",
characteristics=characteristics,
hair_config=hair_config, hair_config=hair_config,
eyes="", eyes="",
descriptor_detail=descriptor_detail, descriptor_detail=descriptor_detail,
@@ -1702,6 +1908,12 @@ NODE_CLASS_MAPPINGS = {
"SxCPHairLength": SxCPHairLength, "SxCPHairLength": SxCPHairLength,
"SxCPHairColor": SxCPHairColor, "SxCPHairColor": SxCPHairColor,
"SxCPHairStyle": SxCPHairStyle, "SxCPHairStyle": SxCPHairStyle,
"SxCPCharacterAgeRange": SxCPCharacterAgeRange,
"SxCPCharacterBodyPool": SxCPCharacterBodyPool,
"SxCPWomanBodyPool": SxCPWomanBodyPool,
"SxCPManBodyPool": SxCPManBodyPool,
"SxCPEyeColorPool": SxCPEyeColorPool,
"SxCPCharacterClothing": SxCPCharacterClothing,
"SxCPCharacterManualDetails": SxCPCharacterManualDetails, "SxCPCharacterManualDetails": SxCPCharacterManualDetails,
"SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPAdvancedFilters": SxCPAdvancedFilters,
"SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs,
@@ -1732,6 +1944,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPHairLength": "SxCP Hair Length", "SxCPHairLength": "SxCP Hair Length",
"SxCPHairColor": "SxCP Hair Color", "SxCPHairColor": "SxCP Hair Color",
"SxCPHairStyle": "SxCP Hair Style/Cut", "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", "SxCPCharacterManualDetails": "SxCP Character Manual Details",
"SxCPAdvancedFilters": "SxCP Advanced Filters", "SxCPAdvancedFilters": "SxCP Advanced Filters",
"SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs",
+277 -13
View File
@@ -271,6 +271,27 @@ CHARACTER_HAIR_STYLE_CHOICES = [
"wet_hair", "wet_hair",
"slicked_back", "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"] CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
@@ -1761,6 +1782,10 @@ def character_hair_style_choices() -> list[str]:
return list(CHARACTER_HAIR_STYLE_CHOICES) 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]: def character_ethnicity_choices() -> list[str]:
return ["random"] + list(ETHNICITY_FILTER_CHOICES) return ["random"] + list(ETHNICITY_FILTER_CHOICES)
@@ -1777,6 +1802,31 @@ def hardcore_detail_density_choices() -> list[str]:
return list(HARDCORE_DETAIL_DENSITY_CHOICES) 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]: def camera_orbit_framing_choices() -> list[str]:
return list(CAMERA_ORBIT_FRAMING_CHOICES) return list(CAMERA_ORBIT_FRAMING_CHOICES)
@@ -2565,6 +2615,157 @@ def _slot_value(value: Any) -> str:
return text 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: def _normalize_descriptor_detail(value: Any) -> str:
text = str(value or "auto").strip() text = str(value or "auto").strip()
return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto" 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"), "skin": manual_fallback("skin"),
"hair": manual_fallback("hair"), "hair": manual_fallback("hair"),
"manual": manual_config, "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": ( "hair_config": (
slot.get("hair_config") slot.get("hair_config")
if isinstance(slot.get("hair_config"), dict) 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']}") parts.append(f"soft_outfit={slot['softcore_outfit']}")
if slot.get("hardcore_clothing"): if slot.get("hardcore_clothing"):
parts.append(f"hard_clothing={slot['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 = _parse_hair_config(slot.get("hair_config"))
hair_config_summary = _hair_config_summary(hair_config) hair_config_summary = _hair_config_summary(hair_config)
if hair_config_summary != "hair unrestricted": if hair_config_summary != "hair unrestricted":
@@ -3256,6 +3466,7 @@ def build_character_slot_json(
body_phrase: str = "", body_phrase: str = "",
skin: str = "", skin: str = "",
hair: str = "", hair: str = "",
characteristics: str | dict[str, Any] | None = "",
hair_config: str | dict[str, Any] | None = "", hair_config: str | dict[str, Any] | None = "",
hair_color: str = "random", hair_color: str = "random",
hair_length: str = "random", hair_length: str = "random",
@@ -3288,6 +3499,7 @@ def build_character_slot_json(
"body_phrase": body_phrase, "body_phrase": body_phrase,
"skin": skin, "skin": skin,
"hair": hair, "hair": hair,
"characteristics": characteristics,
"hair_config": hair_config, "hair_config": hair_config,
"hair_color": hair_color, "hair_color": hair_color,
"hair_length": hair_length, "hair_length": hair_length,
@@ -3423,12 +3635,26 @@ def _body_exposure_scene_text(scene: Any) -> str:
return _clean_prompt_punctuation(text) return _clean_prompt_punctuation(text)
def _slot_softcore_outfit(slot: dict[str, Any] | None) -> str: def _slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
return _slot_value(slot.get("softcore_outfit")) if slot else "" 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: def _slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
return _slot_value(slot.get("hardcore_clothing")) if slot else "" 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: def _softcore_outfit_sentence(label: str, outfit: str) -> str:
@@ -3460,6 +3686,7 @@ def _character_hardcore_clothing_entries(
women_count: int, women_count: int,
men_count: int, men_count: int,
pov_labels: list[str] | None = None, pov_labels: list[str] | None = None,
rng: random.Random | None = None,
) -> list[str]: ) -> list[str]:
pov_set = set(pov_labels or []) pov_set = set(pov_labels or [])
labels = [ labels = [
@@ -3470,7 +3697,7 @@ def _character_hardcore_clothing_entries(
for label in labels: for label in labels:
if label in pov_set: if label in pov_set:
continue continue
clothing = _slot_hardcore_clothing(label_map.get(label)) clothing = _slot_hardcore_clothing(label_map.get(label), rng)
sentence = _hardcore_clothing_sentence(label, clothing) sentence = _hardcore_clothing_sentence(label, clothing)
if sentence: if sentence:
entries.append(sentence) entries.append(sentence)
@@ -3502,8 +3729,11 @@ def _context_from_character_slot(
effective_no_black, 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")) body_phrase = _slot_value(slot.get("body_phrase"))
if not slot_body:
slot_body = _characteristic_choice(characteristics, "bodies", appearance_rng)
if age: if age:
context["age"] = age context["age"] = age
if slot_body: if slot_body:
@@ -3514,10 +3744,14 @@ def _context_from_character_slot(
context["body_phrase"] = f"{slot_body} figure" context["body_phrase"] = f"{slot_body} figure"
if body_phrase: if body_phrase:
context["body_phrase"] = body_phrase context["body_phrase"] = body_phrase
for key in ("skin", "eyes"): skin_value = _slot_value(slot.get("skin"))
value = _slot_value(slot.get(key)) if skin_value:
if value: context["skin"] = skin_value
context[key] = 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")) hair_value = _slot_value(slot.get("hair"))
if hair_value: if hair_value:
context["hair"] = 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( def build_insta_of_options_json(
softcore_cast: str = "solo", softcore_cast: str = "solo",
hardcore_cast: str = "use_counts", hardcore_cast: str = "use_counts",
@@ -5534,7 +5796,7 @@ def _insta_of_partner_styling(
for index in range(max(0, women_count - 1)): for index in range(max(0, women_count - 1)):
label = chr(ord("B") + index) label = chr(ord("B") + index)
full_label = f"Woman {label}" 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) sentence = _softcore_outfit_sentence(full_label, outfit)
if sentence: if sentence:
outfits.append(sentence) outfits.append(sentence)
@@ -5543,7 +5805,7 @@ def _insta_of_partner_styling(
full_label = f"Man {label}" full_label = f"Man {label}"
if full_label in pov_set: if full_label in pov_set:
continue 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) sentence = _softcore_outfit_sentence(full_label, outfit)
if sentence: if sentence:
outfits.append(sentence) outfits.append(sentence)
@@ -5594,6 +5856,7 @@ def build_insta_of_pair(
softcore_level_key = str(options["softcore_level"]) softcore_level_key = str(options["softcore_level"])
soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key) soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key)
soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311) 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_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_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 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" soft_row["character_slot_status"] = "applied:Woman A"
if not soft_expression_enabled: if not soft_expression_enabled:
soft_row = _disable_row_expression(soft_row, soft_expression_intensity_source) 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["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["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" 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_women_count,
hard_men_count, hard_men_count,
pov_character_labels, pov_character_labels,
hard_content_rng,
) )
has_primary_hardcore_clothing = any(entry.startswith("Woman A") for entry in character_hardcore_clothing_entries) 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( fallback_hard_clothing_state = "" if has_primary_hardcore_clothing else _insta_of_hardcore_clothing_state(