from __future__ import annotations import json import random from typing import Any try: from . import character_config as character_policy from . import character_profile as character_profile_policy from . import filter_config as filter_policy from . import pov_policy from . import seed_config as seed_policy except ImportError: # Allows local smoke tests with top-level imports. import character_config as character_policy import character_profile as character_profile_policy import filter_config as filter_policy import pov_policy import seed_config as seed_policy def _is_false(value: Any) -> bool: if isinstance(value, bool): return value is False if isinstance(value, str): return value.strip().lower() in ("false", "0", "no", "off") return False def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float: try: number = float(value) except (TypeError, ValueError): return default return max(min_value, min(max_value, number)) def normalize_slot_expression_intensity(value: Any) -> float: try: intensity = float(value) except (TypeError, ValueError): return -1.0 if intensity < 0: return -1.0 return _clamped_float(intensity, 0.5) def slot_expression_enabled(slot: dict[str, Any] | None) -> bool: if not slot: return True return not _is_false(slot.get("expression_enabled", True)) def slot_expression_intensity(slot: dict[str, Any] | None) -> float | None: if not slot or not slot_expression_enabled(slot): return None intensity = normalize_slot_expression_intensity(slot.get("expression_intensity")) return intensity if intensity >= 0 else None def slot_expression_intensity_for_phase(slot: dict[str, Any] | None, phase: str = "") -> float | None: if not slot or not slot_expression_enabled(slot): return None phase_key = f"{phase}_expression_intensity" if phase in ("softcore", "hardcore") else "" if phase_key: intensity = normalize_slot_expression_intensity(slot.get(phase_key)) if intensity >= 0: return intensity return slot_expression_intensity(slot) def normalize_slot_seed(value: Any) -> int: return character_policy.normalize_slot_seed(value) def slot_seed(slot: dict[str, Any] | None) -> int: if not slot: return -1 return normalize_slot_seed(slot.get("slot_seed")) def slot_seeded_rng(slot: dict[str, Any] | None, salt: int) -> random.Random | None: seed = slot_seed(slot) if seed < 0: return None return random.Random(seed_policy.row_seed(seed, 1, salt)) def slot_context_rng(slot: dict[str, Any], fallback_rng: random.Random) -> random.Random: return slot_seeded_rng(slot, 701) or fallback_rng def slot_effective_figure( slot: dict[str, Any], subject_type: str, fallback_figure: str, ) -> str: raw_figure = str(slot.get("figure") or "random").strip() if raw_figure in ("curvy", "balanced", "bombshell"): return raw_figure seeded_rng = slot_seeded_rng(slot, 709) if subject_type == "woman" and seeded_rng is not None: options = ["curvy", "balanced", "bombshell"] return options[seeded_rng.randrange(len(options))] return fallback_figure def slot_manual_or_choice(choice: str, manual_value: str) -> str: choice = str(choice or "").strip() manual_value = str(manual_value or "").strip() if choice == "manual": return manual_value or "random" if choice.lower() in character_policy.CHARACTER_RANDOM_TOKENS: return "random" return choice def normalize_slot_ethnicity(value: Any) -> str: return filter_policy.normalize_ethnicity_filter(value, "random", allow_random=True) def normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]: subject_type = str(slot.get("subject_type") or slot.get("subject") or "").strip().lower() if subject_type not in ("woman", "man"): subject_type = "woman" label = str(slot.get("label") or slot.get("label_mode") or "auto_chain").strip() label = label.replace("Woman ", "").replace("Man ", "").strip().upper() if label == "AUTO_CHAIN": label = "auto_chain" if label not in character_policy.CHARACTER_LABEL_CHOICES: label = "auto_chain" manual_config = character_profile_policy.parse_character_manual_config(slot.get("manual") or slot.get("manual_config")) raw_age = str(slot.get("age") or "random") raw_manual_age = str(slot.get("manual_age") or "").strip() if not raw_manual_age and manual_config.get("manual_age"): raw_manual_age = manual_config["manual_age"] if raw_age.lower() in character_policy.CHARACTER_RANDOM_TOKENS: raw_age = "manual" age = slot_manual_or_choice(raw_age, raw_manual_age) raw_body = str(slot.get("body") or "random") raw_manual_body = str(slot.get("manual_body") or "").strip() if not raw_manual_body and manual_config.get("manual_body"): raw_manual_body = manual_config["manual_body"] if raw_body.lower() in character_policy.CHARACTER_RANDOM_TOKENS: raw_body = "manual" body = slot_manual_or_choice(raw_body, raw_manual_body) figure = str(slot.get("figure") or "random").strip() if figure not in character_policy.CHARACTER_FIGURE_CHOICES: figure = "random" def manual_fallback(field: str) -> str: direct = character_policy.slot_value(slot.get(field)) return direct or manual_config.get(field, "") normalized = { "profile_type": "character_slot", "subject_type": subject_type, "label": label, "slot_seed": normalize_slot_seed(slot.get("slot_seed")), "age": age, "ethnicity": normalize_slot_ethnicity(slot.get("ethnicity")), "figure": figure, "body": body, "body_phrase": manual_fallback("body_phrase"), "skin": manual_fallback("skin"), "hair": manual_fallback("hair"), "manual": manual_config, "characteristics": ( slot.get("characteristics") if isinstance(slot.get("characteristics"), dict) else character_policy.slot_value(slot.get("characteristics") or slot.get("characteristics_config")) ), "hair_config": ( slot.get("hair_config") if isinstance(slot.get("hair_config"), dict) else character_policy.slot_value(slot.get("hair_config")) ), "hair_color": character_policy.normalize_hair_choice(slot.get("hair_color"), character_policy.CHARACTER_HAIR_COLOR_CHOICES), "hair_length": character_policy.normalize_hair_choice( slot.get("hair_length"), character_policy.CHARACTER_HAIR_LENGTH_CHOICES, ), "hair_style": character_policy.normalize_hair_choice(slot.get("hair_style"), character_policy.CHARACTER_HAIR_STYLE_CHOICES), "eyes": manual_fallback("eyes"), "descriptor_detail": character_policy.normalize_descriptor_detail(slot.get("descriptor_detail")), "presence_mode": character_policy.normalize_presence_mode(slot.get("presence_mode"), subject_type), "softcore_outfit": manual_fallback("softcore_outfit"), "hardcore_clothing": ( character_policy.slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit")) or manual_config.get("hardcore_clothing", "") ), "expression_enabled": not _is_false(slot.get("expression_enabled", True)), "expression_intensity": normalize_slot_expression_intensity(slot.get("expression_intensity")), "softcore_expression_intensity": normalize_slot_expression_intensity(slot.get("softcore_expression_intensity")), "hardcore_expression_intensity": normalize_slot_expression_intensity(slot.get("hardcore_expression_intensity")), } normalized["summary"] = character_slot_summary(normalized) return normalized def parse_character_cast(character_cast: str | dict[str, Any] | list[Any] | None) -> list[dict[str, Any]]: if not character_cast: return [] if isinstance(character_cast, list): raw = character_cast elif isinstance(character_cast, dict): raw = character_cast else: try: raw = json.loads(str(character_cast)) except json.JSONDecodeError as exc: raise ValueError(f"Invalid character_cast JSON: {exc}") from exc if isinstance(raw, list): slots = raw elif isinstance(raw, dict) and isinstance(raw.get("slots"), list): slots = raw["slots"] elif isinstance(raw, dict) and raw.get("profile_type") == "character_slot": slots = [raw] elif isinstance(raw, dict) and raw.get("subject_type") in ("woman", "man"): slots = [raw] else: return [] return [normalize_character_slot(slot) for slot in slots if isinstance(slot, dict)] def character_slot_summary(slot: dict[str, Any]) -> str: subject = str(slot.get("subject_type") or "woman") label = str(slot.get("label") or "auto_chain") label_text = "nearest free label" if label == "auto_chain" else f"{subject.capitalize()} {label}" parts = [ subject, label_text, f"seed={slot.get('slot_seed')}" if slot_seed(slot) >= 0 else "", f"age={slot.get('age', 'random')}", f"ethnicity={slot.get('ethnicity', 'random')}", f"figure={slot.get('figure', 'random')}", f"body={slot.get('body', 'random')}", f"detail={slot.get('descriptor_detail', 'auto')}", ] parts = [part for part in parts if part] if pov_policy.slot_is_pov(slot): parts.append("presence=pov") if not slot_expression_enabled(slot): parts.append("expression=disabled") else: expression_intensity = slot_expression_intensity(slot) if expression_intensity is not None: parts.append(f"expression={expression_intensity:.2f}") softcore_expression_intensity = slot_expression_intensity_for_phase(slot, "softcore") hardcore_expression_intensity = slot_expression_intensity_for_phase(slot, "hardcore") if softcore_expression_intensity is not None and softcore_expression_intensity != expression_intensity: parts.append(f"soft_expr={softcore_expression_intensity:.2f}") if hardcore_expression_intensity is not None and hardcore_expression_intensity != expression_intensity: parts.append(f"hard_expr={hardcore_expression_intensity:.2f}") if slot.get("softcore_outfit"): parts.append(f"soft_outfit={slot['softcore_outfit']}") if slot.get("hardcore_clothing"): parts.append(f"hard_clothing={slot['hardcore_clothing']}") characteristics = character_policy.parse_characteristics_config(slot.get("characteristics")) characteristics_summary = character_policy.characteristics_summary(characteristics) if characteristics_summary != "characteristics unrestricted": parts.append(f"characteristics={characteristics_summary}") hair_config = character_policy.parse_hair_config(slot.get("hair_config")) hair_config_summary = character_policy.hair_config_summary(hair_config) if hair_config_summary != "hair unrestricted": parts.append(f"hair={hair_config_summary}") for key in ("hair_color", "hair_length", "hair_style"): value = slot.get(key) if value and value != "random": parts.append(f"{key}={value}") for key in ("body_phrase", "skin", "hair", "eyes"): value = slot.get(key) if value: parts.append(f"{key}={value}") return "; ".join(parts) def build_character_slot_json( subject_type: str = "woman", label: str = "auto_chain", slot_seed: int = -1, age: str = "random", manual_age: str = "", manual: str | dict[str, Any] | None = "", ethnicity: str = "random", figure: str = "random", body: str = "random", manual_body: str = "", body_phrase: str = "", skin: str = "", hair: str = "", characteristics: str | dict[str, Any] | None = "", hair_config: str | dict[str, Any] | None = "", hair_color: str = "random", hair_length: str = "random", hair_style: str = "random", eyes: str = "", descriptor_detail: str = "auto", expression_enabled: bool = True, expression_intensity: float = -1.0, enabled: bool = True, character_cast: str | dict[str, Any] | list[Any] | None = "", presence_mode: str = "visible", softcore_expression_intensity: float = -1.0, hardcore_expression_intensity: float = -1.0, softcore_outfit: str = "", hardcore_clothing: str = "", ) -> dict[str, str]: existing_slots = parse_character_cast(character_cast) slot = normalize_character_slot( { "subject_type": subject_type, "label": label, "slot_seed": slot_seed, "age": age, "manual_age": manual_age, "manual": manual, "ethnicity": ethnicity, "figure": figure, "body": body, "manual_body": manual_body, "body_phrase": body_phrase, "skin": skin, "hair": hair, "characteristics": characteristics, "hair_config": hair_config, "hair_color": hair_color, "hair_length": hair_length, "hair_style": hair_style, "eyes": eyes, "descriptor_detail": descriptor_detail, "presence_mode": presence_mode, "softcore_outfit": softcore_outfit, "hardcore_clothing": hardcore_clothing, "expression_enabled": expression_enabled, "expression_intensity": expression_intensity, "softcore_expression_intensity": softcore_expression_intensity, "hardcore_expression_intensity": hardcore_expression_intensity, } ) slots = existing_slots + ([slot] if enabled else []) cast = { "profile_type": "character_cast", "version": 1, "slots": slots, } return { "character_cast": json.dumps(cast, ensure_ascii=True, sort_keys=True), "character_slot": json.dumps(slot, ensure_ascii=True, sort_keys=True) if enabled else "", "summary": slot["summary"] if enabled else "disabled", "status": f"{len(slots)} slot(s)", }