From 6a3f88ef593e7450db9ae55b29a00fa5d2225784 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 00:56:23 +0200 Subject: [PATCH] Extract character config policy --- character_config.py | 682 +++++++++++++++++++ docs/prompt-architecture-improvement-plan.md | 4 + docs/prompt-pool-routing-map.md | 1 + node_character.py | 48 +- prompt_builder.py | 586 ++-------------- tools/prompt_smoke.py | 50 ++ 6 files changed, 824 insertions(+), 547 deletions(-) create mode 100644 character_config.py diff --git a/character_config.py b/character_config.py new file mode 100644 index 0000000..024e241 --- /dev/null +++ b/character_config.py @@ -0,0 +1,682 @@ +from __future__ import annotations + +import json +import random +import re +from typing import Any + + +CHARACTER_LABEL_CHOICES = [ + "auto_chain", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", +] +CHARACTER_AGE_CHOICES = ( + ["random", "manual"] + + [f"{age}-year-old adult" for age in range(21, 86)] + + [ + "late 20s adult", + "early 30s adult", + "mid 30s adult", + "late 30s adult", + "early 40s adult", + "mid 40s adult", + "late 40s adult", + "early 50s adult", + "mid 50s adult", + "late 50s adult", + "early 60s adult", + "mid 60s adult", + "late 60s adult", + "early 70s adult", + "mid 70s adult", + "late 70s adult", + "early 80s adult", + ] +) +CHARACTER_BODY_CHOICES = [ + "random", + "manual", + "slim", + "petite adult", + "toned", + "athletic", + "average", + "curvy", + "soft curvy", + "curvy athletic", + "hourglass", + "slim busty", + "busty", + "busty curvy", + "voluptuous", + "plus-size", + "heavyset", + "fat", + "stocky", + "broad", + "muscular", +] +CHARACTER_WOMAN_BODY_CHOICES = [ + "random", + "manual", + "slim", + "petite adult", + "toned", + "athletic", + "average", + "curvy", + "soft curvy", + "curvy athletic", + "hourglass", + "slim busty", + "busty", + "busty curvy", + "voluptuous", + "plus-size", + "heavyset", + "fat", +] +CHARACTER_MAN_BODY_CHOICES = [ + "random", + "manual", + "slim", + "lean", + "lean athletic", + "toned", + "average", + "athletic", + "muscular", + "broad", + "broad-shouldered", + "stocky", + "heavyset", + "fat", +] +CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"] +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", +] +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", +] +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 character_label_choices() -> list[str]: + return list(CHARACTER_LABEL_CHOICES) + + +def character_age_choices() -> list[str]: + return list(CHARACTER_AGE_CHOICES) + + +def character_body_choices() -> list[str]: + return list(CHARACTER_BODY_CHOICES) + + +def character_woman_body_choices() -> list[str]: + return list(CHARACTER_WOMAN_BODY_CHOICES) + + +def character_man_body_choices() -> list[str]: + return list(CHARACTER_MAN_BODY_CHOICES) + + +def character_descriptor_detail_choices() -> list[str]: + return list(CHARACTER_DESCRIPTOR_DETAIL_CHOICES) + + +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_eye_color_choices() -> list[str]: + return list(CHARACTER_EYE_COLOR_CHOICES) + + +def slot_value(value: Any) -> str: + text = str(value or "").strip() + if text.lower() in CHARACTER_RANDOM_TOKENS: + return "" + return text + + +def normalize_descriptor_detail(value: Any) -> str: + text = str(value or "auto").strip() + return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto" + + +def normalize_presence_mode(value: Any, subject_type: str) -> str: + text = str(value or "visible").strip().lower() + if text not in CHARACTER_PRESENCE_CHOICES: + text = "visible" + if subject_type != "man": + return "visible" + return text + + +def normalize_slot_seed(value: Any) -> int: + try: + seed = int(value) + except (TypeError, ValueError): + return -1 + if seed < 0: + return -1 + return min(seed, CHARACTER_SLOT_SEED_MAX) + + +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 values[rng.randrange(len(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_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 pool[rng.randrange(len(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" + + +_slot_value = slot_value +_normalize_descriptor_detail = normalize_descriptor_detail +_normalize_presence_mode = normalize_presence_mode +_normalize_slot_seed = normalize_slot_seed +_empty_characteristics_config = empty_characteristics_config +_normalize_characteristic_choice = normalize_characteristic_choice +_normalize_characteristic_values = normalize_characteristic_values +_parse_characteristics_config = parse_characteristics_config +_characteristics_summary = characteristics_summary +_characteristic_choice = characteristic_choice +_eye_phrase_from_key = eye_phrase_from_key +_normalize_hair_choice = normalize_hair_choice +_infer_hair_color_key = infer_hair_color_key +_infer_hair_length_key = infer_hair_length_key +_infer_hair_style_key = infer_hair_style_key +_choose_hair_key = choose_hair_key +_normalize_hair_values = normalize_hair_values +_empty_hair_config = empty_hair_config +_parse_hair_config = parse_hair_config +_hair_config_summary = hair_config_summary +_hair_color_text = hair_color_text +_hair_length_text = hair_length_text +_hair_phrase_from_parts = hair_phrase_from_parts diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 98bfe60..70d9f58 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -110,6 +110,10 @@ Already isolated: - ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter parsing, and ethnicity normalization live in `filter_config.py`; character routes and builder filters use `prompt_builder.py` delegate wrappers. +- character choice lists, descriptor detail/presence/slot-seed normalization, + characteristic-list JSON builders/parsers, eye labels, hair config + builders/parsers, and hair phrase helpers live in `character_config.py`; + `prompt_builder.py` still resolves full character slots and profiles. - generation profile presets, override normalization, trigger policy, and profile config parsing live in `generation_profile_config.py`; `prompt_builder.py` keeps public delegate wrappers. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index c1f0e1f..be0c791 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -70,6 +70,7 @@ Core helper ownership: | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | | `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. | | `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | +| `character_config.py` | Character choice lists, descriptor detail/presence/slot-seed normalization, characteristic-list JSON builders/parsers, eye labels, hair config builders/parsers, and hair phrase helpers. | | `filter_config.py` | Ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter parsing, and ethnicity normalization used by builder and character routes. | | `generation_profile_config.py` | Generation profile presets, profile option overrides, trigger policy, expression/pose/clothing config normalization, and profile config parsing. | | `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. | diff --git a/node_character.py b/node_character.py index 3f9d171..3c16f5f 100644 --- a/node_character.py +++ b/node_character.py @@ -3,57 +3,61 @@ from __future__ import annotations import json try: + from .character_config import ( + build_characteristics_config_json, + build_hair_config_json, + character_age_choices, + character_body_choices, + character_descriptor_detail_choices, + character_eye_color_choices, + character_hair_color_choices, + character_hair_length_choices, + character_hair_style_choices, + character_label_choices, + character_man_body_choices, + character_presence_choices, + character_woman_body_choices, + ) from .prompt_builder import ( build_character_manual_config_json, build_character_profile_json, build_character_slot_json, + character_ethnicity_choices, + character_figure_choices, + character_hardcore_clothing_state_choices, + character_hardcore_clothing_values, + character_profile_choices, + character_softcore_outfit_source_choices, + character_softcore_outfit_values, + load_character_profile_json, + ) +except ImportError: # Allows local smoke tests from the repository root. + from character_config import ( build_characteristics_config_json, build_hair_config_json, character_age_choices, character_body_choices, character_descriptor_detail_choices, - character_ethnicity_choices, character_eye_color_choices, - character_figure_choices, character_hair_color_choices, character_hair_length_choices, character_hair_style_choices, - character_hardcore_clothing_state_choices, - character_hardcore_clothing_values, character_label_choices, character_man_body_choices, character_presence_choices, - character_profile_choices, - character_softcore_outfit_source_choices, - character_softcore_outfit_values, character_woman_body_choices, - load_character_profile_json, ) -except ImportError: # Allows local smoke tests from the repository root. from prompt_builder import ( build_character_manual_config_json, build_character_profile_json, build_character_slot_json, - build_characteristics_config_json, - build_hair_config_json, - character_age_choices, - character_body_choices, - character_descriptor_detail_choices, character_ethnicity_choices, - character_eye_color_choices, character_figure_choices, - character_hair_color_choices, - character_hair_length_choices, - character_hair_style_choices, character_hardcore_clothing_state_choices, character_hardcore_clothing_values, - character_label_choices, - character_man_body_choices, - character_presence_choices, character_profile_choices, character_softcore_outfit_source_choices, character_softcore_outfit_values, - character_woman_body_choices, load_character_profile_json, ) diff --git a/prompt_builder.py b/prompt_builder.py index 018c551..b63c88e 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -24,6 +24,7 @@ try: template_list as _template_list, ) from . import camera_config as camera_policy + from . import character_config as character_policy from . import category_cast_config as category_cast_policy from . import filter_config as filter_policy from . import generate_prompt_batches as g @@ -66,6 +67,7 @@ except ImportError: # Allows local smoke tests with `python -c`. template_list as _template_list, ) import camera_config as camera_policy + import character_config as character_policy import category_cast_config as category_cast_policy import filter_config as filter_policy import generate_prompt_batches as g @@ -117,180 +119,19 @@ ETHNICITY_BASE_LIST_KEYS = filter_policy.ETHNICITY_BASE_LIST_KEYS EUROPEAN_REGIONAL_LIST_KEYS = filter_policy.EUROPEAN_REGIONAL_LIST_KEYS MEDITERRANEAN_REGIONAL_LIST_KEYS = filter_policy.MEDITERRANEAN_REGIONAL_LIST_KEYS -CHARACTER_LABEL_CHOICES = [ - "auto_chain", - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", - "J", - "K", - "L", -] -CHARACTER_AGE_CHOICES = ( - ["random", "manual"] - + [f"{age}-year-old adult" for age in range(21, 86)] - + [ - "late 20s adult", - "early 30s adult", - "mid 30s adult", - "late 30s adult", - "early 40s adult", - "mid 40s adult", - "late 40s adult", - "early 50s adult", - "mid 50s adult", - "late 50s adult", - "early 60s adult", - "mid 60s adult", - "late 60s adult", - "early 70s adult", - "mid 70s adult", - "late 70s adult", - "early 80s adult", - ] -) -CHARACTER_BODY_CHOICES = [ - "random", - "manual", - "slim", - "petite adult", - "toned", - "athletic", - "average", - "curvy", - "soft curvy", - "curvy athletic", - "hourglass", - "slim busty", - "busty", - "busty curvy", - "voluptuous", - "plus-size", - "heavyset", - "fat", - "stocky", - "broad", - "muscular", -] -CHARACTER_WOMAN_BODY_CHOICES = [ - "random", - "manual", - "slim", - "petite adult", - "toned", - "athletic", - "average", - "curvy", - "soft curvy", - "curvy athletic", - "hourglass", - "slim busty", - "busty", - "busty curvy", - "voluptuous", - "plus-size", - "heavyset", - "fat", -] -CHARACTER_MAN_BODY_CHOICES = [ - "random", - "manual", - "slim", - "lean", - "lean athletic", - "toned", - "average", - "athletic", - "muscular", - "broad", - "broad-shouldered", - "stocky", - "heavyset", - "fat", -] -CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"] -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", -] -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", -] +CHARACTER_LABEL_CHOICES = character_policy.CHARACTER_LABEL_CHOICES +CHARACTER_AGE_CHOICES = character_policy.CHARACTER_AGE_CHOICES +CHARACTER_BODY_CHOICES = character_policy.CHARACTER_BODY_CHOICES +CHARACTER_WOMAN_BODY_CHOICES = character_policy.CHARACTER_WOMAN_BODY_CHOICES +CHARACTER_MAN_BODY_CHOICES = character_policy.CHARACTER_MAN_BODY_CHOICES +CHARACTER_DESCRIPTOR_DETAIL_CHOICES = character_policy.CHARACTER_DESCRIPTOR_DETAIL_CHOICES +CHARACTER_PRESENCE_CHOICES = character_policy.CHARACTER_PRESENCE_CHOICES +CHARACTER_RANDOM_TOKENS = character_policy.CHARACTER_RANDOM_TOKENS +CHARACTER_SLOT_SEED_MAX = character_policy.CHARACTER_SLOT_SEED_MAX +CHARACTER_HAIR_COLOR_CHOICES = character_policy.CHARACTER_HAIR_COLOR_CHOICES +CHARACTER_HAIR_LENGTH_CHOICES = character_policy.CHARACTER_HAIR_LENGTH_CHOICES +CHARACTER_HAIR_STYLE_CHOICES = character_policy.CHARACTER_HAIR_STYLE_CHOICES +CHARACTER_EYE_COLOR_CHOICES = character_policy.CHARACTER_EYE_COLOR_CHOICES CAMERA_DETAIL_CHOICES = camera_policy.CAMERA_DETAIL_CHOICES HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] @@ -1556,47 +1397,47 @@ def ethnicity_choices() -> list[str]: def character_label_choices() -> list[str]: - return list(CHARACTER_LABEL_CHOICES) + return character_policy.character_label_choices() def character_age_choices() -> list[str]: - return list(CHARACTER_AGE_CHOICES) + return character_policy.character_age_choices() def character_body_choices() -> list[str]: - return list(CHARACTER_BODY_CHOICES) + return character_policy.character_body_choices() def character_woman_body_choices() -> list[str]: - return list(CHARACTER_WOMAN_BODY_CHOICES) + return character_policy.character_woman_body_choices() def character_man_body_choices() -> list[str]: - return list(CHARACTER_MAN_BODY_CHOICES) + return character_policy.character_man_body_choices() def character_descriptor_detail_choices() -> list[str]: - return list(CHARACTER_DESCRIPTOR_DETAIL_CHOICES) + return character_policy.character_descriptor_detail_choices() def character_presence_choices() -> list[str]: - return list(CHARACTER_PRESENCE_CHOICES) + return character_policy.character_presence_choices() def character_hair_color_choices() -> list[str]: - return list(CHARACTER_HAIR_COLOR_CHOICES) + return character_policy.character_hair_color_choices() def character_hair_length_choices() -> list[str]: - return list(CHARACTER_HAIR_LENGTH_CHOICES) + return character_policy.character_hair_length_choices() def character_hair_style_choices() -> list[str]: - return list(CHARACTER_HAIR_STYLE_CHOICES) + return character_policy.character_hair_style_choices() def character_eye_color_choices() -> list[str]: - return list(CHARACTER_EYE_COLOR_CHOICES) + return character_policy.character_eye_color_choices() def character_ethnicity_choices() -> list[str]: @@ -2202,39 +2043,18 @@ def build_character_manual_config_json( def _slot_value(value: Any) -> str: - text = str(value or "").strip() - if text.lower() in CHARACTER_RANDOM_TOKENS: - return "" - return text + return character_policy.slot_value(value) -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, -} +CHARACTER_CHARACTERISTIC_AXES = character_policy.CHARACTER_CHARACTERISTIC_AXES def _empty_characteristics_config() -> dict[str, Any]: - return { - "config_type": "characteristics", - "ages": [], - "bodies": [], - "eyes": [], - "softcore_outfits": [], - "hardcore_clothing": [], - } + return character_policy.empty_characteristics_config() 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 "" + return character_policy.normalize_characteristic_choice(value, choices) def _normalize_characteristic_values( @@ -2243,63 +2063,15 @@ def _normalize_characteristic_values( *, 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 + return character_policy.normalize_characteristic_values(values, choices, allow_free_text=allow_free_text) 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), - } + return character_policy.parse_characteristics_config(value) 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" + return character_policy.characteristics_summary(config) def build_characteristics_config_json( @@ -2308,69 +2080,28 @@ def build_characteristics_config_json( 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, + return character_policy.build_characteristics_config_json( + characteristics=characteristics, + axis=axis, + selected_values=selected_values, + combine_mode=combine_mode, ) - 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 "" + return character_policy.characteristic_choice(config, key, rng) 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, "") + return character_policy.eye_phrase_from_key(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" + return character_policy.normalize_descriptor_detail(value) def _normalize_presence_mode(value: Any, subject_type: str) -> str: - text = str(value or "visible").strip().lower() - if text not in CHARACTER_PRESENCE_CHOICES: - text = "visible" - if subject_type != "man": - return "visible" - return text + return character_policy.normalize_presence_mode(value, subject_type) def _slot_is_pov(slot: dict[str, Any] | None) -> bool: @@ -2414,13 +2145,7 @@ def _slot_expression_intensity_for_phase(slot: dict[str, Any] | None, phase: str def _normalize_slot_seed(value: Any) -> int: - try: - seed = int(value) - except (TypeError, ValueError): - return -1 - if seed < 0: - return -1 - return min(seed, CHARACTER_SLOT_SEED_MAX) + return character_policy.normalize_slot_seed(value) def _slot_seed(slot: dict[str, Any] | None) -> int: @@ -2685,149 +2410,39 @@ def _normalize_slot_ethnicity(value: Any) -> str: 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" + return character_policy.normalize_hair_choice(value, choices) 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" + return character_policy.infer_hair_color_key(text) 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" + return character_policy.infer_hair_length_key(text) 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" + return character_policy.infer_hair_style_key(text) 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" + return character_policy.choose_hair_key(rng, choices) 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 + return character_policy.normalize_hair_values(values, choices) def _empty_hair_config() -> dict[str, Any]: - return {"config_type": "hair_characteristics", "colors": [], "lengths": [], "styles": []} + return character_policy.empty_hair_config() 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), - } + return character_policy.parse_hair_config(value) 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" + return character_policy.hair_config_summary(config) def build_hair_config_json( @@ -2836,103 +2451,24 @@ def build_hair_config_json( 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) + return character_policy.build_hair_config_json( + hair_config=hair_config, + axis=axis, + selected_values=selected_values, + combine_mode=combine_mode, + ) 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") + return character_policy.hair_color_text(key) 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, "") + return character_policy.hair_length_text(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" + return character_policy.hair_phrase_from_parts(color_key, length_key, style_key) def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 03f4c1b..cc0e3af 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -24,6 +24,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) import caption_naturalizer # noqa: E402 +import character_config # noqa: E402 import category_cast_config # noqa: E402 import category_library # noqa: E402 import filter_config # noqa: E402 @@ -662,6 +663,54 @@ def smoke_filter_config_policy() -> None: _expect(pb.normalize_ethnicity_filter("random", "any", allow_random=False) == "any", "Ethnicity default normalization changed") +def smoke_character_config_policy() -> None: + _expect(pb.CHARACTER_LABEL_CHOICES is character_config.CHARACTER_LABEL_CHOICES, "Prompt builder character choices are not delegated") + _expect("21-year-old adult" in character_config.character_age_choices(), "Character age choices lost adult ages") + _expect("fat" in character_config.character_man_body_choices(), "Man body pool lost fat option") + _expect("platinum_blonde" in character_config.character_hair_color_choices(), "Hair color choices lost platinum blonde") + + traits = json.loads( + pb.build_characteristics_config_json( + axis="bodies", + selected_values=["slim", "bad value", "slim", "fat"], + combine_mode="replace_axis", + ) + ) + _expect(traits.get("bodies") == ["slim", "fat"], "Character body trait normalization changed") + merged_traits = json.loads( + character_config.build_characteristics_config_json( + characteristics=traits, + axis="eyes", + selected_values=["blue", "gray-brown", "blue"], + combine_mode="add_to_axis", + ) + ) + _expect(merged_traits.get("bodies") == ["slim", "fat"], "Character trait merge lost existing axis") + _expect(merged_traits.get("eyes") == ["blue", "gray_brown"], "Character eye trait normalization changed") + _expect(pb._characteristic_choice({"ages": ["21-year-old adult"]}, "ages", random.Random(1)) == "21-year-old adult", "Trait choice changed") + + hair = json.loads( + pb.build_hair_config_json( + axis="color", + selected_values=["platinum blonde", "bad", "dark-brown"], + combine_mode="replace_axis", + ) + ) + _expect(hair.get("colors") == ["platinum_blonde", "dark_brown"], "Hair color normalization changed") + hair = json.loads( + character_config.build_hair_config_json( + hair_config=hair, + axis="style", + selected_values=["messy bun", "straight"], + combine_mode="add_to_axis", + ) + ) + _expect(hair.get("styles") == ["messy_bun", "straight"], "Hair style config merge changed") + _expect(pb._hair_phrase_from_parts("platinum_blonde", "long", "messy_bun") == "long platinum-blonde hair in a messy bun", "Hair phrase helper changed") + _expect(character_config.normalize_presence_mode("pov", "woman") == "visible", "POV presence should stay man-only") + _expect(character_config.normalize_slot_seed(0xFFFFFFFF + 99) == 0xFFFFFFFF, "Slot seed clamp changed") + + def smoke_hardcore_position_config_policy() -> None: _expect( pb.HARDCORE_POSITION_FAMILY_CHOICES is hardcore_position_config.HARDCORE_POSITION_FAMILY_CHOICES, @@ -2630,6 +2679,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("category_cast_config_policy", smoke_category_cast_config_policy), ("generation_profile_config_policy", smoke_generation_profile_config_policy), ("filter_config_policy", smoke_filter_config_policy), + ("character_config_policy", smoke_character_config_policy), ("hardcore_position_config_policy", smoke_hardcore_position_config_policy), ("category_library_route", smoke_category_library_route), ("hardcore_category_routes", smoke_hardcore_category_routes),