Add chainable character characteristics nodes
This commit is contained in:
+277
-13
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user