diff --git a/character_config.py b/character_config.py index 024e241..5b1e18a 100644 --- a/character_config.py +++ b/character_config.py @@ -107,6 +107,7 @@ CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "min CHARACTER_PRESENCE_CHOICES = ["visible", "pov"] CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"} CHARACTER_SLOT_SEED_MAX = 0xFFFFFFFF +CHARACTER_FIGURE_CHOICES = ["random", "curvy", "balanced", "bombshell"] CHARACTER_HAIR_COLOR_CHOICES = [ "random", "black", @@ -215,6 +216,10 @@ def character_presence_choices() -> list[str]: return list(CHARACTER_PRESENCE_CHOICES) +def character_figure_choices() -> list[str]: + return list(CHARACTER_FIGURE_CHOICES) + + def character_hair_color_choices() -> list[str]: return list(CHARACTER_HAIR_COLOR_CHOICES) @@ -661,6 +666,7 @@ _slot_value = slot_value _normalize_descriptor_detail = normalize_descriptor_detail _normalize_presence_mode = normalize_presence_mode _normalize_slot_seed = normalize_slot_seed +_character_figure_choices = character_figure_choices _empty_characteristics_config = empty_characteristics_config _normalize_characteristic_choice = normalize_characteristic_choice _normalize_characteristic_values = normalize_characteristic_values diff --git a/character_slot.py b/character_slot.py new file mode 100644 index 0000000..0cb03ba --- /dev/null +++ b/character_slot.py @@ -0,0 +1,355 @@ +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)", + } diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index dcd2393..05149ae 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -140,7 +140,12 @@ Already isolated: - 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. + `prompt_builder.py` keeps public delegate wrappers. +- character slot JSON construction, character-cast parsing, slot normalization, + slot summary text, slot expression override policy, slot seed helpers, and + slot figure/ethnicity normalization live in `character_slot.py`; + `prompt_builder.py` keeps public delegate wrappers and still resolves + generation-time appearance context from normalized slots. - character manual-detail config, profile name/path policy, profile JSON normalization, descriptor assembly, save/load/rename/delete operations, fallback profile loading, and context override application live in diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 1ef57d0..bb1f895 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -74,6 +74,7 @@ Core helper ownership: | `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. | | `character_profile.py` | Character manual-detail config, profile name/path policy, profile JSON normalization, descriptor assembly, save/load/rename/delete operations, fallback profile loading, and context override application. | +| `character_slot.py` | Character slot JSON construction, character-cast parsing, slot normalization, slot summary text, slot expression override policy, slot seed helpers, and slot figure/ethnicity normalization. | | `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. | @@ -389,8 +390,9 @@ Important behavior: Edit targets: -- Appearance field generation: `_context_from_character_slot`, - `_character_context_for_label`; character-slot label assignment: +- Character slot JSON/parsing/summary: `character_slot.py`; appearance field + generation: `_context_from_character_slot`, `_character_context_for_label`; + character-slot label assignment: `cast_context.character_slot_label_map`; pair cast descriptor entry assembly: `pair_cast.cast_descriptor_entries`. - Profile save/load: `SxCPCharacterProfileSave`, diff --git a/node_character.py b/node_character.py index 6470f2b..3afd138 100644 --- a/node_character.py +++ b/node_character.py @@ -10,6 +10,7 @@ try: character_body_choices, character_descriptor_detail_choices, character_eye_color_choices, + character_figure_choices, character_hair_color_choices, character_hair_length_choices, character_hair_style_choices, @@ -23,11 +24,10 @@ try: character_profile_choices, load_character_profile_json, ) + from .character_slot import build_character_slot_json from .prompt_builder import ( build_character_profile_json, - build_character_slot_json, character_ethnicity_choices, - character_figure_choices, character_hardcore_clothing_state_choices, character_hardcore_clothing_values, character_softcore_outfit_source_choices, @@ -41,6 +41,7 @@ except ImportError: # Allows local smoke tests from the repository root. character_body_choices, character_descriptor_detail_choices, character_eye_color_choices, + character_figure_choices, character_hair_color_choices, character_hair_length_choices, character_hair_style_choices, @@ -54,11 +55,10 @@ except ImportError: # Allows local smoke tests from the repository root. character_profile_choices, load_character_profile_json, ) + from character_slot import build_character_slot_json from prompt_builder import ( build_character_profile_json, - build_character_slot_json, character_ethnicity_choices, - character_figure_choices, character_hardcore_clothing_state_choices, character_hardcore_clothing_values, character_softcore_outfit_source_choices, diff --git a/prompt_builder.py b/prompt_builder.py index 62beab0..208c078 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -28,6 +28,7 @@ try: from . import category_template_metadata as item_template_policy from . import character_config as character_policy from . import character_profile as character_profile_policy + from . import character_slot as character_slot_policy from . import category_cast_config as category_cast_policy from . import filter_config as filter_policy from . import generate_prompt_batches as g @@ -72,6 +73,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import category_template_metadata as item_template_policy import character_config as character_policy import character_profile as character_profile_policy + import character_slot as character_slot_policy import category_cast_config as category_cast_policy import filter_config as filter_policy import generate_prompt_batches as g @@ -1240,7 +1242,7 @@ def character_ethnicity_choices() -> list[str]: def character_figure_choices() -> list[str]: - return ["random", "curvy", "balanced", "bombshell"] + return character_policy.character_figure_choices() def camera_detail_choices() -> list[str]: @@ -1798,37 +1800,19 @@ def _slot_is_pov(slot: dict[str, Any] | None) -> bool: 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) + return character_slot_policy.normalize_slot_expression_intensity(value) def _slot_expression_enabled(slot: dict[str, Any] | None) -> bool: - if not slot: - return True - return not _is_false(slot.get("expression_enabled", True)) + return character_slot_policy.slot_expression_enabled(slot) 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 + return character_slot_policy.slot_expression_intensity(slot) 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) + return character_slot_policy.slot_expression_intensity_for_phase(slot, phase) def _normalize_slot_seed(value: Any) -> int: @@ -1836,20 +1820,15 @@ def _normalize_slot_seed(value: Any) -> int: def _slot_seed(slot: dict[str, Any] | None) -> int: - if not slot: - return -1 - return _normalize_slot_seed(slot.get("slot_seed")) + return character_slot_policy.slot_seed(slot) 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(_row_seed(seed, 1, salt)) + return character_slot_policy.slot_seeded_rng(slot, salt) def _slot_context_rng(slot: dict[str, Any], fallback_rng: random.Random) -> random.Random: - return _slot_seeded_rng(slot, 701) or fallback_rng + return character_slot_policy.slot_context_rng(slot, fallback_rng) def _slot_effective_figure( @@ -1857,13 +1836,7 @@ def _slot_effective_figure( 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: - return g.choose(seeded_rng, ["curvy", "balanced", "bombshell"]) - return fallback_figure + return character_slot_policy.slot_effective_figure(slot, subject_type, fallback_figure) def _mean(values: list[float]) -> float: @@ -2074,17 +2047,11 @@ def _descriptor_from_parts( 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_RANDOM_TOKENS: - return "random" - return choice + return character_slot_policy.slot_manual_or_choice(choice, manual_value) def _normalize_slot_ethnicity(value: Any) -> str: - return normalize_ethnicity_filter(value, "random", allow_random=True) + return character_slot_policy.normalize_slot_ethnicity(value) def _normalize_hair_choice(value: Any, choices: list[str]) -> str: @@ -2198,160 +2165,15 @@ def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random 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_LABEL_CHOICES: - label = "auto_chain" - - manual_config = _parse_character_manual_config(slot.get("manual") or slot.get("manual_config")) - - raw_age = str(slot.get("age") or "random") - raw_manual_age = str(slot.get("manual_age") or "").strip() - if not raw_manual_age and manual_config.get("manual_age"): - raw_manual_age = manual_config["manual_age"] - if raw_age.lower() in CHARACTER_RANDOM_TOKENS: - raw_age = "manual" - age = _slot_manual_or_choice(raw_age, raw_manual_age) - - raw_body = str(slot.get("body") or "random") - raw_manual_body = str(slot.get("manual_body") or "").strip() - if not raw_manual_body and manual_config.get("manual_body"): - raw_manual_body = manual_config["manual_body"] - if raw_body.lower() in CHARACTER_RANDOM_TOKENS: - raw_body = "manual" - body = _slot_manual_or_choice(raw_body, raw_manual_body) - figure = str(slot.get("figure") or "random").strip() - if figure not in character_figure_choices(): - figure = "random" - - def manual_fallback(field: str) -> str: - direct = _slot_value(slot.get(field)) - return direct or manual_config.get(field, "") - - normalized = { - "profile_type": "character_slot", - "subject_type": subject_type, - "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 _slot_value(slot.get("characteristics") or slot.get("characteristics_config")) - ), - "hair_config": ( - slot.get("hair_config") - if isinstance(slot.get("hair_config"), dict) - else _slot_value(slot.get("hair_config")) - ), - "hair_color": _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES), - "hair_length": _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES), - "hair_style": _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES), - "eyes": manual_fallback("eyes"), - "descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")), - "presence_mode": _normalize_presence_mode(slot.get("presence_mode"), subject_type), - "softcore_outfit": manual_fallback("softcore_outfit"), - "hardcore_clothing": ( - _slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit")) - or manual_config.get("hardcore_clothing", "") - ), - "expression_enabled": not _is_false(slot.get("expression_enabled", True)), - "expression_intensity": _normalize_slot_expression_intensity(slot.get("expression_intensity")), - "softcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("softcore_expression_intensity")), - "hardcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("hardcore_expression_intensity")), - } - normalized["summary"] = _character_slot_summary(normalized) - return normalized + return character_slot_policy.normalize_character_slot(slot) 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)] + return character_slot_policy.parse_character_cast(character_cast) 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 _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 = _parse_characteristics_config(slot.get("characteristics")) - characteristics_summary = _characteristics_summary(characteristics) - if characteristics_summary != "characteristics unrestricted": - parts.append(f"characteristics={characteristics_summary}") - hair_config = _parse_hair_config(slot.get("hair_config")) - hair_config_summary = _hair_config_summary(hair_config) - if hair_config_summary != "hair unrestricted": - parts.append(f"hair={hair_config_summary}") - for key in ("hair_color", "hair_length", "hair_style"): - value = slot.get(key) - if value and value != "random": - parts.append(f"{key}={value}") - for key in ("body_phrase", "skin", "hair", "eyes"): - value = slot.get(key) - if value: - parts.append(f"{key}={value}") - return "; ".join(parts) + return character_slot_policy.character_slot_summary(slot) def build_character_slot_json( @@ -2385,50 +2207,37 @@ def build_character_slot_json( 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, - } + return character_slot_policy.build_character_slot_json( + 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, + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, + enabled=enabled, + character_cast=character_cast, + presence_mode=presence_mode, + softcore_expression_intensity=softcore_expression_intensity, + hardcore_expression_intensity=hardcore_expression_intensity, + softcore_outfit=softcore_outfit, + hardcore_clothing=hardcore_clothing, ) - 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)", - } def _slot_explicit_label(slot: dict[str, Any]) -> str: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 03ecba8..4115856 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -30,6 +30,7 @@ import cast_context # noqa: E402 import category_template_metadata # noqa: E402 import character_config # noqa: E402 import character_profile # noqa: E402 +import character_slot # noqa: E402 import category_cast_config # noqa: E402 import category_library # noqa: E402 import filter_config # noqa: E402 @@ -804,6 +805,7 @@ 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(pb.character_figure_choices() == character_config.character_figure_choices(), "Character figure choices should delegate") _expect("platinum_blonde" in character_config.character_hair_color_choices(), "Hair color choices lost platinum blonde") traits = json.loads( @@ -872,6 +874,67 @@ def smoke_character_config_policy() -> None: "Krea POV composition cleanup should delegate shared replacements and strip builder annotation", ) _expect(character_config.normalize_slot_seed(0xFFFFFFFF + 99) == 0xFFFFFFFF, "Slot seed clamp changed") + slot_result = character_slot.build_character_slot_json( + subject_type="man", + label="Man B", + slot_seed=123, + age="manual", + manual_age="44-year-old adult", + ethnicity="western_european", + figure="balanced", + body="manual", + manual_body="stocky", + descriptor_detail="compact", + expression_intensity=1.5, + softcore_expression_intensity=0.25, + hardcore_expression_intensity=-1, + presence_mode="pov", + hair_color="dark brown", + hair_length="short", + hair_style="straight", + softcore_outfit="buttoned shirt", + hardcore_clothing="shirt pushed open", + ) + _expect( + pb.build_character_slot_json( + subject_type="man", + label="Man B", + slot_seed=123, + age="manual", + manual_age="44-year-old adult", + ethnicity="western_european", + figure="balanced", + body="manual", + manual_body="stocky", + descriptor_detail="compact", + expression_intensity=1.5, + softcore_expression_intensity=0.25, + hardcore_expression_intensity=-1, + presence_mode="pov", + hair_color="dark brown", + hair_length="short", + hair_style="straight", + softcore_outfit="buttoned shirt", + hardcore_clothing="shirt pushed open", + ) + == slot_result, + "Prompt builder character slot JSON should delegate to character_slot", + ) + slot = json.loads(slot_result["character_slot"]) + _expect(slot.get("age") == "44-year-old adult", "Character slot manual age normalization changed") + _expect(slot.get("body") == "stocky", "Character slot manual body normalization changed") + _expect(slot.get("presence_mode") == "pov", "Character slot POV presence normalization changed") + _expect(slot.get("expression_intensity") == 1.0, "Character slot expression intensity clamp changed") + _expect( + character_slot.slot_expression_intensity_for_phase(slot, "softcore") == 0.25 + and character_slot.slot_expression_intensity_for_phase(slot, "hardcore") == 1.0, + "Character slot phase expression fallback changed", + ) + _expect( + pb._slot_effective_figure({"slot_seed": 123, "figure": "random"}, "woman", "curvy") + == character_slot.slot_effective_figure({"slot_seed": 123, "figure": "random"}, "woman", "curvy"), + "Prompt builder seeded slot figure should delegate to character_slot", + ) def smoke_character_profile_policy() -> None: