Add chainable hair and manual character nodes

This commit is contained in:
2026-06-25 00:01:19 +02:00
parent ed6c7b8dc0
commit 01b59df30e
3 changed files with 698 additions and 96 deletions
+496 -9
View File
@@ -219,6 +219,58 @@ CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "min
CHARACTER_PRESENCE_CHOICES = ["visible", "pov"]
CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
CHARACTER_SLOT_SEED_MAX = 0xFFFFFFFF
CHARACTER_HAIR_COLOR_CHOICES = [
"random",
"black",
"brown",
"dark_brown",
"chestnut",
"auburn",
"copper",
"red",
"blonde",
"platinum_blonde",
"ash_blonde",
"honey_blonde",
"strawberry_blonde",
"dark_blonde",
"silver_gray",
"white",
]
CHARACTER_HAIR_LENGTH_CHOICES = [
"random",
"very_short",
"short",
"bob_lob",
"shoulder_length",
"medium",
"long",
"very_long",
"updo",
]
CHARACTER_HAIR_STYLE_CHOICES = [
"random",
"straight",
"waves",
"loose_waves",
"curls",
"tight_curls",
"pixie_cut",
"bob",
"lob",
"shag",
"ponytail",
"braid",
"braids",
"bun",
"messy_bun",
"locs",
"twists",
"afro",
"natural_curls",
"wet_hair",
"slicked_back",
]
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
@@ -1697,6 +1749,18 @@ def character_presence_choices() -> list[str]:
return list(CHARACTER_PRESENCE_CHOICES)
def character_hair_color_choices() -> list[str]:
return list(CHARACTER_HAIR_COLOR_CHOICES)
def character_hair_length_choices() -> list[str]:
return list(CHARACTER_HAIR_LENGTH_CHOICES)
def character_hair_style_choices() -> list[str]:
return list(CHARACTER_HAIR_STYLE_CHOICES)
def character_ethnicity_choices() -> list[str]:
return ["random"] + list(ETHNICITY_FILTER_CHOICES)
@@ -2426,6 +2490,74 @@ def _load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[st
return raw
CHARACTER_MANUAL_FIELDS = (
"manual_age",
"manual_body",
"body_phrase",
"skin",
"hair",
"eyes",
"softcore_outfit",
"hardcore_clothing",
)
def _parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]:
if not value:
return {}
if isinstance(value, dict):
raw = value
else:
try:
raw = json.loads(str(value))
except json.JSONDecodeError:
return {}
if not isinstance(raw, dict):
return {}
return {
key: str(raw.get(key) or "").strip()
for key in CHARACTER_MANUAL_FIELDS
if str(raw.get(key) or "").strip()
}
def _character_manual_summary(config: dict[str, str]) -> str:
parts = [f"{key}={value}" for key, value in config.items() if value]
return "; ".join(parts) if parts else "manual unrestricted"
def build_character_manual_config_json(
manual: str | dict[str, Any] | None = "",
combine_mode: str = "merge_nonempty",
manual_age: str = "",
manual_body: str = "",
body_phrase: str = "",
skin: str = "",
hair: str = "",
eyes: str = "",
softcore_outfit: str = "",
hardcore_clothing: str = "",
) -> str:
base = {} if combine_mode == "replace_all" else _parse_character_manual_config(manual)
updates = {
"manual_age": manual_age,
"manual_body": manual_body,
"body_phrase": body_phrase,
"skin": skin,
"hair": hair,
"eyes": eyes,
"softcore_outfit": softcore_outfit,
"hardcore_clothing": hardcore_clothing,
}
for key, value in updates.items():
value = str(value or "").strip()
if value:
base[key] = value
result = {"config_type": "character_manual", **base}
result["summary"] = _character_manual_summary(base)
return json.dumps(result, ensure_ascii=True, sort_keys=True)
def _slot_value(value: Any) -> str:
text = str(value or "").strip()
if text.lower() in CHARACTER_RANDOM_TOKENS:
@@ -2663,6 +2795,305 @@ def _normalize_slot_ethnicity(value: Any) -> str:
return normalize_ethnicity_filter(value, "random", allow_random=True)
def _normalize_hair_choice(value: Any, choices: list[str]) -> str:
text = str(value or "random").strip().lower().replace("-", "_").replace(" ", "_")
return text if text in choices else "random"
def _infer_hair_color_key(text: Any) -> str:
value = str(text or "").lower()
checks = (
("platinum_blonde", ("platinum-blonde", "platinum blonde", "platinum")),
("strawberry_blonde", ("strawberry-blonde", "strawberry blonde")),
("honey_blonde", ("honey-blonde", "honey blonde")),
("ash_blonde", ("ash-blonde", "ash blonde")),
("dark_blonde", ("dark-blonde", "dark blonde")),
(
"blonde",
(
"light-blonde",
"light blonde",
"blonde",
"flaxen",
"wheat-blonde",
"wheat blonde",
"beige-blonde",
"beige blonde",
"sandy-blonde",
"sandy blonde",
),
),
("silver_gray", ("silver-gray", "silver grey", "silver", "gray", "grey")),
("dark_brown", ("dark-brown", "dark brown", "espresso")),
("chestnut", ("chestnut",)),
("auburn", ("auburn",)),
("copper", ("copper",)),
("red", ("red hair", "redhead")),
("black", ("black",)),
("brown", ("brown", "brunette", "caramel")),
("white", ("white",)),
)
for key, tokens in checks:
if any(token in value for token in tokens):
return key
return "random"
def _infer_hair_length_key(text: Any) -> str:
value = str(text or "").lower()
if any(token in value for token in ("very long", "waist-length", "hip-length")):
return "very_long"
if "long" in value:
return "long"
if "shoulder-length" in value or "shoulder length" in value:
return "shoulder_length"
if "medium-length" in value or "medium length" in value:
return "medium"
if any(token in value for token in ("bob", "lob")):
return "bob_lob"
if any(token in value for token in ("pixie", "short", "cropped", "tapered")):
return "short"
if any(token in value for token in ("bun", "updo")):
return "updo"
return "random"
def _infer_hair_style_key(text: Any) -> str:
value = str(text or "").lower()
checks = (
("pixie_cut", ("pixie",)),
("messy_bun", ("messy bun",)),
("bun", ("bun", "updo")),
("ponytail", ("ponytail",)),
("braids", ("braids", "box braids", "cornrow")),
("braid", ("braid",)),
("locs", ("locs", "dreadlocks")),
("twists", ("twists",)),
("afro", ("afro",)),
("natural_curls", ("natural curls", "natural coils", "coils")),
("tight_curls", ("tight curls", "tight coils")),
("curls", ("curls", "curly")),
("loose_waves", ("loose waves",)),
("waves", ("waves", "wavy")),
("lob", ("lob",)),
("bob", ("bob",)),
("shag", ("shag",)),
("wet_hair", ("wet hair", "damp hair")),
("slicked_back", ("slicked-back", "slicked back")),
("straight", ("straight", "sleek")),
)
for key, tokens in checks:
if any(token in value for token in tokens):
return key
return "random"
def _choose_hair_key(rng: random.Random, choices: list[str]) -> str:
pool = [choice for choice in choices if choice != "random"]
return g.choose(rng, pool) if pool else "random"
def _normalize_hair_values(values: Any, choices: list[str]) -> list[str]:
if isinstance(values, str):
raw_values = [part.strip() for part in re.split(r"[,;\n]+", values) if part.strip()]
elif isinstance(values, (list, tuple, set)):
raw_values = list(values)
else:
raw_values = []
normalized: list[str] = []
for value in raw_values:
key = _normalize_hair_choice(value, choices)
if key != "random" and key not in normalized:
normalized.append(key)
return normalized
def _empty_hair_config() -> dict[str, Any]:
return {"config_type": "hair_characteristics", "colors": [], "lengths": [], "styles": []}
def _parse_hair_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
if not value:
return _empty_hair_config()
if isinstance(value, dict):
raw = value
else:
try:
raw = json.loads(str(value))
except json.JSONDecodeError:
return _empty_hair_config()
if not isinstance(raw, dict):
return _empty_hair_config()
return {
"config_type": "hair_characteristics",
"colors": _normalize_hair_values(raw.get("colors"), CHARACTER_HAIR_COLOR_CHOICES),
"lengths": _normalize_hair_values(raw.get("lengths"), CHARACTER_HAIR_LENGTH_CHOICES),
"styles": _normalize_hair_values(raw.get("styles"), CHARACTER_HAIR_STYLE_CHOICES),
}
def _hair_config_summary(config: dict[str, Any]) -> str:
parts = []
for label, key in (("colors", "colors"), ("lengths", "lengths"), ("styles", "styles")):
values = config.get(key) or []
if values:
parts.append(f"{label}={','.join(values)}")
return "; ".join(parts) if parts else "hair unrestricted"
def build_hair_config_json(
hair_config: str | dict[str, Any] | None = "",
axis: str = "color",
selected_values: list[str] | tuple[str, ...] | str | None = None,
combine_mode: str = "replace_axis",
) -> str:
config = _parse_hair_config(hair_config)
axis_key = {"color": "colors", "length": "lengths", "style": "styles"}.get(str(axis or "").strip().lower())
choice_map = {
"colors": CHARACTER_HAIR_COLOR_CHOICES,
"lengths": CHARACTER_HAIR_LENGTH_CHOICES,
"styles": CHARACTER_HAIR_STYLE_CHOICES,
}
if axis_key:
values = _normalize_hair_values(selected_values, choice_map[axis_key])
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"] = _hair_config_summary(config)
return json.dumps(config, ensure_ascii=True, sort_keys=True)
def _hair_color_text(key: str) -> str:
return {
"black": "black",
"brown": "brown",
"dark_brown": "dark-brown",
"chestnut": "chestnut",
"auburn": "auburn",
"copper": "copper",
"red": "red",
"blonde": "blonde",
"platinum_blonde": "platinum-blonde",
"ash_blonde": "ash-blonde",
"honey_blonde": "honey-blonde",
"strawberry_blonde": "strawberry-blonde",
"dark_blonde": "dark-blonde",
"silver_gray": "silver-gray",
"white": "white",
}.get(key, "brown")
def _hair_length_text(key: str) -> str:
return {
"very_short": "very short",
"short": "short",
"bob_lob": "",
"shoulder_length": "shoulder-length",
"medium": "medium-length",
"long": "long",
"very_long": "very long",
"updo": "",
}.get(key, "")
def _hair_phrase_from_parts(color_key: str, length_key: str, style_key: str) -> str:
color = _hair_color_text(color_key)
length = _hair_length_text(length_key)
prefix = " ".join(part for part in (length, color) if part)
if style_key == "pixie_cut":
return f"short {color} pixie cut"
if style_key == "bob":
return f"{color} bob" if length_key in ("random", "bob_lob", "short") else f"{prefix} bob"
if style_key == "lob":
return f"shoulder-length {color} lob" if length_key in ("random", "bob_lob") else f"{prefix} lob"
if style_key == "shag":
return f"{prefix or color} shag"
if style_key == "ponytail":
return f"{prefix or color} ponytail"
if style_key == "braid":
return f"{prefix or color} braid"
if style_key == "braids":
return f"{prefix or color} braids"
if style_key == "bun":
return f"{prefix} hair in a bun" if length else f"{color} bun"
if style_key == "messy_bun":
return f"{prefix} hair in a messy bun" if length else f"messy {color} bun"
if style_key == "locs":
return f"{prefix or color} locs"
if style_key == "twists":
return f"{prefix or color} twists"
if style_key == "afro":
return f"{color} afro"
if style_key == "natural_curls":
return f"{prefix or color} natural curls"
if style_key == "wet_hair":
return f"{prefix or color} wet hair"
if style_key == "slicked_back":
return f"slicked-back {color} hair"
if style_key == "straight":
return f"{prefix or color} straight hair"
if style_key == "loose_waves":
return f"{prefix or color} loose waves"
if style_key == "tight_curls":
return f"{prefix or color} tight curls"
if style_key == "curls":
return f"{prefix or color} curls"
return f"{prefix or color} waves"
def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str:
hair_config = _parse_hair_config(slot.get("hair_config"))
color_choice = _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES)
length_choice = _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES)
style_choice = _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES)
color_options = hair_config.get("colors") or []
length_options = hair_config.get("lengths") or []
style_options = hair_config.get("styles") or []
if (
color_choice == "random"
and length_choice == "random"
and style_choice == "random"
and not color_options
and not length_options
and not style_options
):
return ""
if color_choice != "random":
color_key = color_choice
elif color_options:
color_key = g.choose(rng, color_options)
else:
color_key = _infer_hair_color_key(base_hair)
if length_choice != "random":
length_key = length_choice
elif length_options:
length_key = g.choose(rng, length_options)
else:
length_key = _infer_hair_length_key(base_hair)
if style_choice != "random":
style_key = style_choice
elif style_options:
style_key = g.choose(rng, style_options)
else:
style_key = _infer_hair_style_key(base_hair)
if color_key == "random":
color_key = _choose_hair_key(rng, CHARACTER_HAIR_COLOR_CHOICES)
if length_key == "random":
length_key = _choose_hair_key(rng, CHARACTER_HAIR_LENGTH_CHOICES)
if style_key == "random":
style_key = _choose_hair_key(rng, CHARACTER_HAIR_STYLE_CHOICES)
if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"):
style_key = g.choose(rng, ["ponytail", "braid", "bun", "messy_bun"])
return _hair_phrase_from_parts(color_key, length_key, style_key)
def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
subject_type = str(slot.get("subject_type") or slot.get("subject") or "").strip().lower()
if subject_type not in ("woman", "man"):
@@ -2674,12 +3105,31 @@ def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
if label not in CHARACTER_LABEL_CHOICES:
label = "auto_chain"
age = _slot_manual_or_choice(str(slot.get("age") or "random"), str(slot.get("manual_age") or ""))
body = _slot_manual_or_choice(str(slot.get("body") or "random"), str(slot.get("manual_body") or ""))
manual_config = _parse_character_manual_config(slot.get("manual") or slot.get("manual_config"))
raw_age = str(slot.get("age") or "random")
raw_manual_age = str(slot.get("manual_age") or "").strip()
if not raw_manual_age and manual_config.get("manual_age"):
raw_manual_age = manual_config["manual_age"]
if raw_age.lower() in CHARACTER_RANDOM_TOKENS:
raw_age = "manual"
age = _slot_manual_or_choice(raw_age, raw_manual_age)
raw_body = str(slot.get("body") or "random")
raw_manual_body = str(slot.get("manual_body") or "").strip()
if not raw_manual_body and manual_config.get("manual_body"):
raw_manual_body = manual_config["manual_body"]
if raw_body.lower() in CHARACTER_RANDOM_TOKENS:
raw_body = "manual"
body = _slot_manual_or_choice(raw_body, raw_manual_body)
figure = str(slot.get("figure") or "random").strip()
if figure not in character_figure_choices():
figure = "random"
def manual_fallback(field: str) -> str:
direct = _slot_value(slot.get(field))
return direct or manual_config.get(field, "")
normalized = {
"profile_type": "character_slot",
"subject_type": subject_type,
@@ -2689,14 +3139,26 @@ def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
"ethnicity": _normalize_slot_ethnicity(slot.get("ethnicity")),
"figure": figure,
"body": body,
"body_phrase": _slot_value(slot.get("body_phrase")),
"skin": _slot_value(slot.get("skin")),
"hair": _slot_value(slot.get("hair")),
"eyes": _slot_value(slot.get("eyes")),
"body_phrase": manual_fallback("body_phrase"),
"skin": manual_fallback("skin"),
"hair": manual_fallback("hair"),
"manual": manual_config,
"hair_config": (
slot.get("hair_config")
if isinstance(slot.get("hair_config"), dict)
else _slot_value(slot.get("hair_config"))
),
"hair_color": _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES),
"hair_length": _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES),
"hair_style": _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES),
"eyes": manual_fallback("eyes"),
"descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")),
"presence_mode": _normalize_presence_mode(slot.get("presence_mode"), subject_type),
"softcore_outfit": _slot_value(slot.get("softcore_outfit")),
"hardcore_clothing": _slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit")),
"softcore_outfit": manual_fallback("softcore_outfit"),
"hardcore_clothing": (
_slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit"))
or manual_config.get("hardcore_clothing", "")
),
"expression_enabled": not _is_false(slot.get("expression_enabled", True)),
"expression_intensity": _normalize_slot_expression_intensity(slot.get("expression_intensity")),
"softcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("softcore_expression_intensity")),
@@ -2765,6 +3227,14 @@ 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']}")
hair_config = _parse_hair_config(slot.get("hair_config"))
hair_config_summary = _hair_config_summary(hair_config)
if hair_config_summary != "hair unrestricted":
parts.append(f"hair={hair_config_summary}")
for key in ("hair_color", "hair_length", "hair_style"):
value = slot.get(key)
if value and value != "random":
parts.append(f"{key}={value}")
for key in ("body_phrase", "skin", "hair", "eyes"):
value = slot.get(key)
if value:
@@ -2778,6 +3248,7 @@ def build_character_slot_json(
slot_seed: int = -1,
age: str = "random",
manual_age: str = "",
manual: str | dict[str, Any] | None = "",
ethnicity: str = "random",
figure: str = "random",
body: str = "random",
@@ -2785,6 +3256,10 @@ def build_character_slot_json(
body_phrase: str = "",
skin: str = "",
hair: str = "",
hair_config: str | dict[str, Any] | None = "",
hair_color: str = "random",
hair_length: str = "random",
hair_style: str = "random",
eyes: str = "",
descriptor_detail: str = "auto",
expression_enabled: bool = True,
@@ -2805,6 +3280,7 @@ def build_character_slot_json(
"slot_seed": slot_seed,
"age": age,
"manual_age": manual_age,
"manual": manual,
"ethnicity": ethnicity,
"figure": figure,
"body": body,
@@ -2812,6 +3288,10 @@ def build_character_slot_json(
"body_phrase": body_phrase,
"skin": skin,
"hair": hair,
"hair_config": hair_config,
"hair_color": hair_color,
"hair_length": hair_length,
"hair_style": hair_style,
"eyes": eyes,
"descriptor_detail": descriptor_detail,
"presence_mode": presence_mode,
@@ -3034,10 +3514,17 @@ def _context_from_character_slot(
context["body_phrase"] = f"{slot_body} figure"
if body_phrase:
context["body_phrase"] = body_phrase
for key in ("skin", "hair", "eyes"):
for key in ("skin", "eyes"):
value = _slot_value(slot.get(key))
if value:
context[key] = value
hair_value = _slot_value(slot.get("hair"))
if hair_value:
context["hair"] = hair_value
else:
hair_descriptor = _hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng)
if hair_descriptor:
context["hair"] = hair_descriptor
context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail"))
context["presence_mode"] = _normalize_presence_mode(slot.get("presence_mode"), subject_type)
context["expression_enabled"] = _slot_expression_enabled(slot)