Extract character config policy

This commit is contained in:
2026-06-27 00:56:23 +02:00
parent 50d0ffa7e3
commit 6a3f88ef59
6 changed files with 824 additions and 547 deletions
+682
View File
@@ -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
@@ -110,6 +110,10 @@ Already isolated:
- ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter - ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter
parsing, and ethnicity normalization live in `filter_config.py`; character parsing, and ethnicity normalization live in `filter_config.py`; character
routes and builder filters use `prompt_builder.py` delegate wrappers. 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 - generation profile presets, override normalization, trigger policy, and
profile config parsing live in `generation_profile_config.py`; profile config parsing live in `generation_profile_config.py`;
`prompt_builder.py` keeps public delegate wrappers. `prompt_builder.py` keeps public delegate wrappers.
+1
View File
@@ -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_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. | | `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. | | `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. | | `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. | | `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. | | `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. |
+26 -22
View File
@@ -3,57 +3,61 @@ from __future__ import annotations
import json import json
try: 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 ( from .prompt_builder import (
build_character_manual_config_json, build_character_manual_config_json,
build_character_profile_json, build_character_profile_json,
build_character_slot_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_characteristics_config_json,
build_hair_config_json, build_hair_config_json,
character_age_choices, character_age_choices,
character_body_choices, character_body_choices,
character_descriptor_detail_choices, character_descriptor_detail_choices,
character_ethnicity_choices,
character_eye_color_choices, character_eye_color_choices,
character_figure_choices,
character_hair_color_choices, character_hair_color_choices,
character_hair_length_choices, character_hair_length_choices,
character_hair_style_choices, character_hair_style_choices,
character_hardcore_clothing_state_choices,
character_hardcore_clothing_values,
character_label_choices, character_label_choices,
character_man_body_choices, character_man_body_choices,
character_presence_choices, character_presence_choices,
character_profile_choices,
character_softcore_outfit_source_choices,
character_softcore_outfit_values,
character_woman_body_choices, character_woman_body_choices,
load_character_profile_json,
) )
except ImportError: # Allows local smoke tests from the repository root.
from prompt_builder import ( from prompt_builder import (
build_character_manual_config_json, build_character_manual_config_json,
build_character_profile_json, build_character_profile_json,
build_character_slot_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_ethnicity_choices,
character_eye_color_choices,
character_figure_choices, character_figure_choices,
character_hair_color_choices,
character_hair_length_choices,
character_hair_style_choices,
character_hardcore_clothing_state_choices, character_hardcore_clothing_state_choices,
character_hardcore_clothing_values, character_hardcore_clothing_values,
character_label_choices,
character_man_body_choices,
character_presence_choices,
character_profile_choices, character_profile_choices,
character_softcore_outfit_source_choices, character_softcore_outfit_source_choices,
character_softcore_outfit_values, character_softcore_outfit_values,
character_woman_body_choices,
load_character_profile_json, load_character_profile_json,
) )
+61 -525
View File
@@ -24,6 +24,7 @@ try:
template_list as _template_list, template_list as _template_list,
) )
from . import camera_config as camera_policy 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 category_cast_config as category_cast_policy
from . import filter_config as filter_policy from . import filter_config as filter_policy
from . import generate_prompt_batches as g 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, template_list as _template_list,
) )
import camera_config as camera_policy import camera_config as camera_policy
import character_config as character_policy
import category_cast_config as category_cast_policy import category_cast_config as category_cast_policy
import filter_config as filter_policy import filter_config as filter_policy
import generate_prompt_batches as g 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 EUROPEAN_REGIONAL_LIST_KEYS = filter_policy.EUROPEAN_REGIONAL_LIST_KEYS
MEDITERRANEAN_REGIONAL_LIST_KEYS = filter_policy.MEDITERRANEAN_REGIONAL_LIST_KEYS MEDITERRANEAN_REGIONAL_LIST_KEYS = filter_policy.MEDITERRANEAN_REGIONAL_LIST_KEYS
CHARACTER_LABEL_CHOICES = [ CHARACTER_LABEL_CHOICES = character_policy.CHARACTER_LABEL_CHOICES
"auto_chain", CHARACTER_AGE_CHOICES = character_policy.CHARACTER_AGE_CHOICES
"A", CHARACTER_BODY_CHOICES = character_policy.CHARACTER_BODY_CHOICES
"B", CHARACTER_WOMAN_BODY_CHOICES = character_policy.CHARACTER_WOMAN_BODY_CHOICES
"C", CHARACTER_MAN_BODY_CHOICES = character_policy.CHARACTER_MAN_BODY_CHOICES
"D", CHARACTER_DESCRIPTOR_DETAIL_CHOICES = character_policy.CHARACTER_DESCRIPTOR_DETAIL_CHOICES
"E", CHARACTER_PRESENCE_CHOICES = character_policy.CHARACTER_PRESENCE_CHOICES
"F", CHARACTER_RANDOM_TOKENS = character_policy.CHARACTER_RANDOM_TOKENS
"G", CHARACTER_SLOT_SEED_MAX = character_policy.CHARACTER_SLOT_SEED_MAX
"H", CHARACTER_HAIR_COLOR_CHOICES = character_policy.CHARACTER_HAIR_COLOR_CHOICES
"I", CHARACTER_HAIR_LENGTH_CHOICES = character_policy.CHARACTER_HAIR_LENGTH_CHOICES
"J", CHARACTER_HAIR_STYLE_CHOICES = character_policy.CHARACTER_HAIR_STYLE_CHOICES
"K", CHARACTER_EYE_COLOR_CHOICES = character_policy.CHARACTER_EYE_COLOR_CHOICES
"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",
]
CAMERA_DETAIL_CHOICES = camera_policy.CAMERA_DETAIL_CHOICES CAMERA_DETAIL_CHOICES = camera_policy.CAMERA_DETAIL_CHOICES
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
@@ -1556,47 +1397,47 @@ def ethnicity_choices() -> list[str]:
def character_label_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]: def character_age_choices() -> list[str]:
return list(CHARACTER_AGE_CHOICES) return character_policy.character_age_choices()
def character_body_choices() -> list[str]: def character_body_choices() -> list[str]:
return list(CHARACTER_BODY_CHOICES) return character_policy.character_body_choices()
def character_woman_body_choices() -> list[str]: 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]: 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]: 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]: def character_presence_choices() -> list[str]:
return list(CHARACTER_PRESENCE_CHOICES) return character_policy.character_presence_choices()
def character_hair_color_choices() -> list[str]: 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]: 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]: 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]: 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]: def character_ethnicity_choices() -> list[str]:
@@ -2202,39 +2043,18 @@ def build_character_manual_config_json(
def _slot_value(value: Any) -> str: def _slot_value(value: Any) -> str:
text = str(value or "").strip() return character_policy.slot_value(value)
if text.lower() in CHARACTER_RANDOM_TOKENS:
return ""
return text
CHARACTER_CHARACTERISTIC_AXES = { CHARACTER_CHARACTERISTIC_AXES = character_policy.CHARACTER_CHARACTERISTIC_AXES
"ages": CHARACTER_AGE_CHOICES,
"bodies": list(dict.fromkeys([*CHARACTER_BODY_CHOICES, *CHARACTER_WOMAN_BODY_CHOICES, *CHARACTER_MAN_BODY_CHOICES])),
"eyes": CHARACTER_EYE_COLOR_CHOICES,
}
def _empty_characteristics_config() -> dict[str, Any]: def _empty_characteristics_config() -> dict[str, Any]:
return { return character_policy.empty_characteristics_config()
"config_type": "characteristics",
"ages": [],
"bodies": [],
"eyes": [],
"softcore_outfits": [],
"hardcore_clothing": [],
}
def _normalize_characteristic_choice(value: Any, choices: list[str] | tuple[str, ...]) -> str: def _normalize_characteristic_choice(value: Any, choices: list[str] | tuple[str, ...]) -> str:
text = str(value or "").strip() return character_policy.normalize_characteristic_choice(value, choices)
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( def _normalize_characteristic_values(
@@ -2243,63 +2063,15 @@ def _normalize_characteristic_values(
*, *,
allow_free_text: bool = False, allow_free_text: bool = False,
) -> list[str]: ) -> list[str]:
if isinstance(values, str): return character_policy.normalize_characteristic_values(values, choices, allow_free_text=allow_free_text)
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]: def _parse_characteristics_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
if not value: return character_policy.parse_characteristics_config(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: def _characteristics_summary(config: dict[str, Any]) -> str:
parts = [] return character_policy.characteristics_summary(config)
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( def build_characteristics_config_json(
@@ -2308,69 +2080,28 @@ def build_characteristics_config_json(
selected_values: list[str] | tuple[str, ...] | str | None = None, selected_values: list[str] | tuple[str, ...] | str | None = None,
combine_mode: str = "replace_axis", combine_mode: str = "replace_axis",
) -> str: ) -> str:
config = _parse_characteristics_config(characteristics) return character_policy.build_characteristics_config_json(
axis_key = str(axis or "").strip().lower() characteristics=characteristics,
if axis_key not in config: axis=axis,
config["summary"] = _characteristics_summary(config) selected_values=selected_values,
return json.dumps(config, ensure_ascii=True, sort_keys=True) combine_mode=combine_mode,
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: def _characteristic_choice(config: dict[str, Any], key: str, rng: random.Random) -> str:
values = config.get(key) or [] return character_policy.characteristic_choice(config, key, rng)
return g.choose(rng, values) if values else ""
def _eye_phrase_from_key(key: str) -> str: def _eye_phrase_from_key(key: str) -> str:
return { return character_policy.eye_phrase_from_key(key)
"blue": "blue eyes",
"pale_blue": "pale blue eyes",
"ice_blue": "ice blue eyes",
"blue_gray": "blue-gray eyes",
"green": "green eyes",
"emerald_green": "emerald green eyes",
"hazel": "hazel eyes",
"light_hazel": "light hazel eyes",
"green_hazel": "green-hazel eyes",
"amber": "amber eyes",
"amber_brown": "amber-brown eyes",
"honey_brown": "honey-brown eyes",
"brown": "brown eyes",
"deep_brown": "deep brown eyes",
"dark_brown": "dark brown eyes",
"dark": "dark eyes",
"gray": "gray eyes",
"gray_brown": "gray-brown eyes",
}.get(key, "")
def _normalize_descriptor_detail(value: Any) -> str: def _normalize_descriptor_detail(value: Any) -> str:
text = str(value or "auto").strip() return character_policy.normalize_descriptor_detail(value)
return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto"
def _normalize_presence_mode(value: Any, subject_type: str) -> str: def _normalize_presence_mode(value: Any, subject_type: str) -> str:
text = str(value or "visible").strip().lower() return character_policy.normalize_presence_mode(value, subject_type)
if text not in CHARACTER_PRESENCE_CHOICES:
text = "visible"
if subject_type != "man":
return "visible"
return text
def _slot_is_pov(slot: dict[str, Any] | None) -> bool: 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: def _normalize_slot_seed(value: Any) -> int:
try: return character_policy.normalize_slot_seed(value)
seed = int(value)
except (TypeError, ValueError):
return -1
if seed < 0:
return -1
return min(seed, CHARACTER_SLOT_SEED_MAX)
def _slot_seed(slot: dict[str, Any] | None) -> int: 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: def _normalize_hair_choice(value: Any, choices: list[str]) -> str:
text = str(value or "random").strip().lower().replace("-", "_").replace(" ", "_") return character_policy.normalize_hair_choice(value, choices)
return text if text in choices else "random"
def _infer_hair_color_key(text: Any) -> str: def _infer_hair_color_key(text: Any) -> str:
value = str(text or "").lower() return character_policy.infer_hair_color_key(text)
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: def _infer_hair_length_key(text: Any) -> str:
value = str(text or "").lower() return character_policy.infer_hair_length_key(text)
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: def _infer_hair_style_key(text: Any) -> str:
value = str(text or "").lower() return character_policy.infer_hair_style_key(text)
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: def _choose_hair_key(rng: random.Random, choices: list[str]) -> str:
pool = [choice for choice in choices if choice != "random"] return character_policy.choose_hair_key(rng, choices)
return g.choose(rng, pool) if pool else "random"
def _normalize_hair_values(values: Any, choices: list[str]) -> list[str]: def _normalize_hair_values(values: Any, choices: list[str]) -> list[str]:
if isinstance(values, str): return character_policy.normalize_hair_values(values, choices)
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]: 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]: def _parse_hair_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
if not value: return character_policy.parse_hair_config(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: def _hair_config_summary(config: dict[str, Any]) -> str:
parts = [] return character_policy.hair_config_summary(config)
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( def build_hair_config_json(
@@ -2836,103 +2451,24 @@ def build_hair_config_json(
selected_values: list[str] | tuple[str, ...] | str | None = None, selected_values: list[str] | tuple[str, ...] | str | None = None,
combine_mode: str = "replace_axis", combine_mode: str = "replace_axis",
) -> str: ) -> str:
config = _parse_hair_config(hair_config) return character_policy.build_hair_config_json(
axis_key = {"color": "colors", "length": "lengths", "style": "styles"}.get(str(axis or "").strip().lower()) hair_config=hair_config,
choice_map = { axis=axis,
"colors": CHARACTER_HAIR_COLOR_CHOICES, selected_values=selected_values,
"lengths": CHARACTER_HAIR_LENGTH_CHOICES, combine_mode=combine_mode,
"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: def _hair_color_text(key: str) -> str:
return { return character_policy.hair_color_text(key)
"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: def _hair_length_text(key: str) -> str:
return { return character_policy.hair_length_text(key)
"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: def _hair_phrase_from_parts(color_key: str, length_key: str, style_key: str) -> str:
color = _hair_color_text(color_key) return character_policy.hair_phrase_from_parts(color_key, length_key, style_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: def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str:
+50
View File
@@ -24,6 +24,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
import caption_naturalizer # noqa: E402 import caption_naturalizer # noqa: E402
import character_config # noqa: E402
import category_cast_config # noqa: E402 import category_cast_config # noqa: E402
import category_library # noqa: E402 import category_library # noqa: E402
import filter_config # 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") _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: def smoke_hardcore_position_config_policy() -> None:
_expect( _expect(
pb.HARDCORE_POSITION_FAMILY_CHOICES is hardcore_position_config.HARDCORE_POSITION_FAMILY_CHOICES, 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), ("category_cast_config_policy", smoke_category_cast_config_policy),
("generation_profile_config_policy", smoke_generation_profile_config_policy), ("generation_profile_config_policy", smoke_generation_profile_config_policy),
("filter_config_policy", smoke_filter_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), ("hardcore_position_config_policy", smoke_hardcore_position_config_policy),
("category_library_route", smoke_category_library_route), ("category_library_route", smoke_category_library_route),
("hardcore_category_routes", smoke_hardcore_category_routes), ("hardcore_category_routes", smoke_hardcore_category_routes),