From 01b59df30e105a55739e85ec3902aa637d63a8d5 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 25 Jun 2026 00:01:19 +0200 Subject: [PATCH] Add chainable hair and manual character nodes --- README.md | 35 +++- __init__.py | 254 ++++++++++++++++------- prompt_builder.py | 505 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 698 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 631cf2f..2ddccef 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ The node is registered as: - `prompt_builder / SxCP Cast Control` - `prompt_builder / SxCP Generation Profile` - `prompt_builder / SxCP Ethnicity List` +- `prompt_builder / SxCP Hair Length` +- `prompt_builder / SxCP Hair Color` +- `prompt_builder / SxCP Hair Style/Cut` +- `prompt_builder / SxCP Character Manual Details` - `prompt_builder / SxCP Advanced Filters` - `prompt_builder / SxCP Prompt Builder From Configs` - `prompt_builder / SxCP Woman Slot` @@ -150,10 +154,15 @@ ComfyUI image batches require matching dimensions. For mixed image formats, use `SxCP Woman Slot` and `SxCP Man Slot` are the scalable per-participant control nodes. `Cast Control` still decides how many women and men are generated; slot -nodes decide who those people are. Each slot defines one participant with -optional overrides for age, ethnicity, body/body phrase, skin, hair, and eyes. -Leave any field on `random` or blank to let the generator fill that part from -the normal pools; set exact values only where you want control. +nodes decide who those people are. Each slot defines one participant with age, +ethnicity, body, expression, and config inputs. Leave fields on `random` to let +the generator fill that part from the normal pools. + +Use `SxCP Character Manual Details` when you need exact manual text for a slot: +manual age, manual body, body phrase, skin, hair, eyes, softcore outfit, or +hardcore clothing. Connect `Character Manual Details.manual` to the slot's +`manual` input. Manual detail nodes are chainable through their top `manual` +input/output. Each slot has `slot_seed`. Leave it at `-1` to follow the generator's normal person seed. Set any fixed value when the slot's `random` fields should resolve @@ -166,6 +175,16 @@ that character should randomize inside a selected heritage list. This is useful for narrowing broad groups, for example choosing French/Germanic/Nordic/Slavic European entries instead of the entire `european` pool. +Hair can be controlled the same way with chainable characteristic nodes. Connect +the final `hair_config` output to a slot's `hair_config` input: + +`Hair Length -> Hair Color -> Hair Style/Cut -> Woman Slot.hair_config` + +Each hair node only changes its own axis and passes the rest through. For +example, select `long` in `Hair Length`, select the blonde variants you allow in +`Hair Color`, then select `waves` and `ponytail` in `Hair Style/Cut`. If the slot's +manual details include `hair`, that exact text overrides the hair config. + Use `Woman Slot` for women because it exposes woman-focused body choices and a `figure_bias` selector. Use `Man Slot` for men because it exposes man-focused body choices and omits figure bias. The older generic `SxCP Character Slot` @@ -204,11 +223,9 @@ For Insta/OF pairs, slots also expose character-level overrides: - `softcore_expression_intensity` and `hardcore_expression_intensity`: override the option-node expression fallback for that character and that output half. -- `softcore_outfit`: overrides the character's softcore clothing. For `Woman A` - this replaces the generated teaser outfit; for partners it replaces random - partner styling. -- `hardcore_clothing`: adds direct character clothing/nudity wording in the - hardcore output. A `Woman A` hardcore clothing override replaces the global +- `softcore_outfit` and `hardcore_clothing` are provided by + `SxCP Character Manual Details` and connected through the slot's `manual` + input. For `Woman A`, a hardcore clothing override replaces the global `hardcore_clothing_continuity` text to avoid contradictory clothing prompts. Slots are chainable through the `character_cast` input/output. In automatic diff --git a/__init__.py b/__init__.py index c50edc6..7ed4941 100644 --- a/__init__.py +++ b/__init__.py @@ -19,10 +19,12 @@ try: build_cast_config_json, build_category_config_json, build_character_slot_json, + build_character_manual_config_json, build_character_profile_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, + build_hair_config_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, @@ -48,6 +50,9 @@ try: character_descriptor_detail_choices, character_ethnicity_choices, character_figure_choices, + character_hair_color_choices, + character_hair_length_choices, + character_hair_style_choices, character_label_choices, character_man_body_choices, character_presence_choices, @@ -72,10 +77,12 @@ except ImportError: build_cast_config_json, build_category_config_json, build_character_slot_json, + build_character_manual_config_json, build_character_profile_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, + build_hair_config_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, @@ -101,6 +108,9 @@ except ImportError: character_descriptor_detail_choices, character_ethnicity_choices, character_figure_choices, + character_hair_color_choices, + character_hair_length_choices, + character_hair_style_choices, character_label_choices, character_man_body_choices, character_presence_choices, @@ -797,6 +807,119 @@ class SxCPEthnicityList: return result["ethnicity"], result["filter_config"], result["summary"] +class _SxCPHairAxisNode: + AXIS = "color" + PREFIX = "include" + + @classmethod + def _choices(cls): + if cls.AXIS == "color": + return [choice for choice in character_hair_color_choices() if choice != "random"] + if cls.AXIS == "length": + return [choice for choice in character_hair_length_choices() if choice != "random"] + return [choice for choice in character_hair_style_choices() if choice != "random"] + + @classmethod + def INPUT_TYPES(cls): + required = { + "combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}), + } + for choice in cls._choices(): + required[f"{cls.PREFIX}_{choice}"] = ("BOOLEAN", {"default": False}) + return { + "required": required, + "optional": { + "hair_config": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("hair_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, combine_mode="replace_axis", hair_config="", **kwargs): + selected = [ + choice + for choice in self._choices() + if bool(kwargs.get(f"{self.PREFIX}_{choice}", False)) + ] + config = build_hair_config_json( + hair_config=hair_config or "", + axis=self.AXIS, + selected_values=selected, + combine_mode=combine_mode, + ) + parsed = json.loads(config) + return config, parsed.get("summary", "") + + +class SxCPHairColor(_SxCPHairAxisNode): + AXIS = "color" + + +class SxCPHairLength(_SxCPHairAxisNode): + AXIS = "length" + + +class SxCPHairStyle(_SxCPHairAxisNode): + AXIS = "style" + + +class SxCPCharacterManualDetails: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "combine_mode": (["merge_nonempty", "replace_all"], {"default": "merge_nonempty"}), + "manual_age": ("STRING", {"default": ""}), + "manual_body": ("STRING", {"default": ""}), + "body_phrase": ("STRING", {"default": ""}), + "skin": ("STRING", {"default": ""}), + "hair": ("STRING", {"default": ""}), + "eyes": ("STRING", {"default": ""}), + "softcore_outfit": ("STRING", {"default": ""}), + "hardcore_clothing": ("STRING", {"default": ""}), + }, + "optional": { + "manual": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("manual", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + combine_mode, + manual_age, + manual_body, + body_phrase, + skin, + hair, + eyes, + softcore_outfit, + hardcore_clothing, + manual="", + ): + config = build_character_manual_config_json( + manual=manual or "", + combine_mode=combine_mode, + 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, + ) + parsed = json.loads(config) + return config, parsed.get("summary", "") + + class SxCPPromptBuilderFromConfigs: @classmethod def INPUT_TYPES(cls): @@ -877,27 +1000,21 @@ class SxCPCharacterSlot: "subject_type": (["woman", "man"], {"default": "woman"}), "label": (character_label_choices(), {"default": "auto_chain"}), "slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}), - "age": (character_age_choices(), {"default": "random"}), - "manual_age": ("STRING", {"default": ""}), + "age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}), "ethnicity": (character_ethnicity_choices(), {"default": "random"}), "figure": (character_figure_choices(), {"default": "random"}), - "body": (character_body_choices(), {"default": "random"}), - "manual_body": ("STRING", {"default": ""}), - "body_phrase": ("STRING", {"default": ""}), - "skin": ("STRING", {"default": ""}), - "hair": ("STRING", {"default": ""}), - "eyes": ("STRING", {"default": ""}), + "body": ([choice for choice in character_body_choices() if choice != "manual"], {"default": "random"}), "descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}), "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "presence_mode": (character_presence_choices(), {"default": "visible"}), "softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), - "softcore_outfit": ("STRING", {"default": ""}), - "hardcore_clothing": ("STRING", {"default": ""}), }, "optional": { + "manual": ("STRING", {"default": "", "multiline": True}), "ethnicity_list": ("STRING", {"default": "", "multiline": True}), + "hair_config": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}), }, } @@ -914,48 +1031,43 @@ class SxCPCharacterSlot: label, slot_seed, age, - manual_age, ethnicity, figure, body, - manual_body, - body_phrase, - skin, - hair, - eyes, descriptor_detail="auto", expression_enabled=True, expression_intensity=-1.0, presence_mode="visible", softcore_expression_intensity=-1.0, hardcore_expression_intensity=-1.0, - softcore_outfit="", - hardcore_clothing="", character_cast="", ethnicity_list="", + hair_config="", + manual="", ): result = build_character_slot_json( subject_type=subject_type, label=label, slot_seed=slot_seed, age=age, - manual_age=manual_age, + manual=manual, ethnicity=ethnicity_list or ethnicity, figure=figure, body=body, - manual_body=manual_body, - body_phrase=body_phrase, - skin=skin, - hair=hair, - eyes=eyes, + manual_body="", + body_phrase="", + skin="", + hair="", + hair_config=hair_config, + eyes="", descriptor_detail=descriptor_detail, expression_enabled=expression_enabled, expression_intensity=expression_intensity, presence_mode=presence_mode, softcore_expression_intensity=softcore_expression_intensity, hardcore_expression_intensity=hardcore_expression_intensity, - softcore_outfit=softcore_outfit, - hardcore_clothing=hardcore_clothing, + softcore_outfit="", + hardcore_clothing="", enabled=enabled, character_cast=character_cast or "", ) @@ -970,26 +1082,20 @@ class SxCPWomanSlot: "enabled": ("BOOLEAN", {"default": True}), "label": (character_label_choices(), {"default": "auto_chain"}), "slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}), - "age": (character_age_choices(), {"default": "random"}), - "manual_age": ("STRING", {"default": ""}), + "age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}), "ethnicity": (character_ethnicity_choices(), {"default": "random"}), "figure_bias": (character_figure_choices(), {"default": "random"}), - "body": (character_woman_body_choices(), {"default": "random"}), - "manual_body": ("STRING", {"default": ""}), - "body_phrase": ("STRING", {"default": ""}), - "skin": ("STRING", {"default": ""}), - "hair": ("STRING", {"default": ""}), - "eyes": ("STRING", {"default": ""}), + "body": ([choice for choice in character_woman_body_choices() if choice != "manual"], {"default": "random"}), "descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}), "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), - "softcore_outfit": ("STRING", {"default": ""}), - "hardcore_clothing": ("STRING", {"default": ""}), }, "optional": { + "manual": ("STRING", {"default": "", "multiline": True}), "ethnicity_list": ("STRING", {"default": "", "multiline": True}), + "hair_config": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}), }, } @@ -1005,46 +1111,41 @@ class SxCPWomanSlot: label, slot_seed, age, - manual_age, ethnicity, figure_bias, body, - manual_body, - body_phrase, - skin, - hair, - eyes, descriptor_detail="auto", expression_enabled=True, expression_intensity=-1.0, softcore_expression_intensity=-1.0, hardcore_expression_intensity=-1.0, - softcore_outfit="", - hardcore_clothing="", character_cast="", ethnicity_list="", + hair_config="", + manual="", ): result = build_character_slot_json( subject_type="woman", label=label, slot_seed=slot_seed, age=age, - manual_age=manual_age, + manual=manual, ethnicity=ethnicity_list or ethnicity, figure=figure_bias, body=body, - manual_body=manual_body, - body_phrase=body_phrase, - skin=skin, - hair=hair, - eyes=eyes, + manual_body="", + body_phrase="", + skin="", + hair="", + hair_config=hair_config, + eyes="", descriptor_detail=descriptor_detail, expression_enabled=expression_enabled, expression_intensity=expression_intensity, softcore_expression_intensity=softcore_expression_intensity, hardcore_expression_intensity=hardcore_expression_intensity, - softcore_outfit=softcore_outfit, - hardcore_clothing=hardcore_clothing, + softcore_outfit="", + hardcore_clothing="", enabled=enabled, character_cast=character_cast or "", ) @@ -1059,26 +1160,20 @@ class SxCPManSlot: "enabled": ("BOOLEAN", {"default": True}), "label": (character_label_choices(), {"default": "auto_chain"}), "slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}), - "age": (character_age_choices(), {"default": "random"}), - "manual_age": ("STRING", {"default": ""}), + "age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}), "ethnicity": (character_ethnicity_choices(), {"default": "random"}), - "body": (character_man_body_choices(), {"default": "random"}), - "manual_body": ("STRING", {"default": ""}), - "body_phrase": ("STRING", {"default": ""}), - "skin": ("STRING", {"default": ""}), - "hair": ("STRING", {"default": ""}), - "eyes": ("STRING", {"default": ""}), + "body": ([choice for choice in character_man_body_choices() if choice != "manual"], {"default": "random"}), "descriptor_detail": (character_descriptor_detail_choices(), {"default": "compact"}), "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "presence_mode": (character_presence_choices(), {"default": "visible"}), "softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), - "softcore_outfit": ("STRING", {"default": ""}), - "hardcore_clothing": ("STRING", {"default": ""}), }, "optional": { + "manual": ("STRING", {"default": "", "multiline": True}), "ethnicity_list": ("STRING", {"default": "", "multiline": True}), + "hair_config": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}), }, } @@ -1094,47 +1189,42 @@ class SxCPManSlot: label, slot_seed, age, - manual_age, ethnicity, body, - manual_body, - body_phrase, - skin, - hair, - eyes, descriptor_detail="compact", expression_enabled=True, expression_intensity=-1.0, presence_mode="visible", softcore_expression_intensity=-1.0, hardcore_expression_intensity=-1.0, - softcore_outfit="", - hardcore_clothing="", character_cast="", ethnicity_list="", + hair_config="", + manual="", ): result = build_character_slot_json( subject_type="man", label=label, slot_seed=slot_seed, age=age, - manual_age=manual_age, + manual=manual, ethnicity=ethnicity_list or ethnicity, figure="random", body=body, - manual_body=manual_body, - body_phrase=body_phrase, - skin=skin, - hair=hair, - eyes=eyes, + manual_body="", + body_phrase="", + skin="", + hair="", + hair_config=hair_config, + eyes="", descriptor_detail=descriptor_detail, expression_enabled=expression_enabled, expression_intensity=expression_intensity, presence_mode=presence_mode, softcore_expression_intensity=softcore_expression_intensity, hardcore_expression_intensity=hardcore_expression_intensity, - softcore_outfit=softcore_outfit, - hardcore_clothing=hardcore_clothing, + softcore_outfit="", + hardcore_clothing="", enabled=enabled, character_cast=character_cast or "", ) @@ -1595,6 +1685,10 @@ NODE_CLASS_MAPPINGS = { "SxCPCastControl": SxCPCastControl, "SxCPGenerationProfile": SxCPGenerationProfile, "SxCPEthnicityList": SxCPEthnicityList, + "SxCPHairLength": SxCPHairLength, + "SxCPHairColor": SxCPHairColor, + "SxCPHairStyle": SxCPHairStyle, + "SxCPCharacterManualDetails": SxCPCharacterManualDetails, "SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, "SxCPWomanSlot": SxCPWomanSlot, @@ -1621,6 +1715,10 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPCastControl": "SxCP Cast Control", "SxCPGenerationProfile": "SxCP Generation Profile", "SxCPEthnicityList": "SxCP Ethnicity List", + "SxCPHairLength": "SxCP Hair Length", + "SxCPHairColor": "SxCP Hair Color", + "SxCPHairStyle": "SxCP Hair Style/Cut", + "SxCPCharacterManualDetails": "SxCP Character Manual Details", "SxCPAdvancedFilters": "SxCP Advanced Filters", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", "SxCPWomanSlot": "SxCP Woman Slot", diff --git a/prompt_builder.py b/prompt_builder.py index 1f21cd8..07cec5a 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -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)