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
+277 -13
View File
@@ -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(