From 70a8698cbee71301fb92a7be9e24150a00b30394 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 08:37:04 +0200 Subject: [PATCH] Extract character appearance policy --- character_appearance.py | 268 +++++++++++++++++++ docs/prompt-architecture-improvement-plan.md | 7 +- docs/prompt-pool-routing-map.md | 7 +- prompt_builder.py | 221 +++------------ tools/prompt_smoke.py | 18 ++ 5 files changed, 326 insertions(+), 195 deletions(-) create mode 100644 character_appearance.py diff --git a/character_appearance.py b/character_appearance.py new file mode 100644 index 0000000..e6b995e --- /dev/null +++ b/character_appearance.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import random +from typing import Any + +try: + 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 generate_prompt_batches as g + 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 character_slot as character_slot_policy + import generate_prompt_batches as g + import seed_config as seed_policy + + +def _choose(rng: random.Random, items: list[Any]) -> Any: + return items[rng.randrange(len(items))] + + +def slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: + if not slot: + return "" + outfit = character_policy.slot_value(slot.get("softcore_outfit")) + if outfit: + return outfit + if rng is None: + return "" + return character_policy.characteristic_choice( + character_policy.parse_characteristics_config(slot.get("characteristics")), + "softcore_outfits", + rng, + ) + + +def slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: + if not slot: + return "" + clothing = character_policy.slot_value(slot.get("hardcore_clothing")) + if clothing: + return clothing + if rng is None: + return "" + return character_policy.characteristic_choice( + character_policy.parse_characteristics_config(slot.get("characteristics")), + "hardcore_clothing", + rng, + ) + + +def hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str: + hair_config = character_policy.parse_hair_config(slot.get("hair_config")) + color_choice = character_policy.normalize_hair_choice(slot.get("hair_color"), character_policy.CHARACTER_HAIR_COLOR_CHOICES) + length_choice = character_policy.normalize_hair_choice(slot.get("hair_length"), character_policy.CHARACTER_HAIR_LENGTH_CHOICES) + style_choice = character_policy.normalize_hair_choice(slot.get("hair_style"), character_policy.CHARACTER_HAIR_STYLE_CHOICES) + color_options = hair_config.get("colors") or [] + length_options = hair_config.get("lengths") or [] + style_options = hair_config.get("styles") or [] + if ( + color_choice == "random" + and length_choice == "random" + and style_choice == "random" + and not color_options + and not length_options + and not style_options + ): + return "" + if color_choice != "random": + color_key = color_choice + elif color_options: + color_key = _choose(rng, color_options) + else: + color_key = character_policy.infer_hair_color_key(base_hair) + + if length_choice != "random": + length_key = length_choice + elif length_options: + length_key = _choose(rng, length_options) + else: + length_key = character_policy.infer_hair_length_key(base_hair) + + if style_choice != "random": + style_key = style_choice + elif style_options: + style_key = _choose(rng, style_options) + else: + style_key = character_policy.infer_hair_style_key(base_hair) + if color_key == "random": + color_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_COLOR_CHOICES) + if length_key == "random": + length_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_LENGTH_CHOICES) + if style_key == "random": + style_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_STYLE_CHOICES) + if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"): + style_key = _choose(rng, ["ponytail", "braid", "bun", "messy_bun"]) + return character_policy.hair_phrase_from_parts(color_key, length_key, style_key) + + +def appearance_for_subject( + rng: random.Random, + subject_type: str, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, +) -> dict[str, str]: + if subject_type == "single_any": + subject_type = "woman" if rng.random() < 0.82 else "man" + + if subject_type == "man": + men_ethnicity = ethnicity if ethnicity else "any" + subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity)) + return { + "subject_type": "man", + "subject": subject, + "subject_phrase": subject, + "age": age, + "body": body, + "skin": skin, + "hair": hair, + "eyes": eyes, + "body_phrase": f"{body} figure", + } + + subject, age, body, skin, hair, eyes = g.choose_woman(rng, ethnicity, no_plus_women, no_black) + figure_note = g.choose(rng, g.figure_pool(figure)) + return { + "subject_type": "woman", + "subject": subject, + "subject_phrase": subject, + "age": age, + "body": body, + "skin": skin, + "hair": hair, + "eyes": eyes, + "body_phrase": character_profile_policy.body_phrase(body, figure_note), + "figure": figure_note, + } + + +def context_from_character_slot( + rng: random.Random, + slot: dict[str, Any], + subject_type: str, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, +) -> dict[str, Any]: + slot_ethnicity = character_policy.slot_value(slot.get("ethnicity")) + slot_body = character_policy.slot_value(slot.get("body")) + effective_ethnicity = slot_ethnicity or ethnicity + effective_figure = character_slot_policy.slot_effective_figure(slot, subject_type, figure) + effective_no_plus = bool(no_plus_women) and not slot_body + effective_no_black = bool(no_black) and not slot_ethnicity + appearance_rng = character_slot_policy.slot_context_rng(slot, rng) + context = appearance_for_subject( + appearance_rng, + subject_type, + effective_ethnicity, + effective_figure, + effective_no_plus, + effective_no_black, + ) + + characteristics = character_policy.parse_characteristics_config(slot.get("characteristics")) + age = character_policy.slot_value(slot.get("age")) or character_policy.characteristic_choice(characteristics, "ages", appearance_rng) + body_phrase = character_policy.slot_value(slot.get("body_phrase")) + if not slot_body: + slot_body = character_policy.characteristic_choice(characteristics, "bodies", appearance_rng) + if age: + context["age"] = age + if slot_body: + context["body"] = slot_body + if subject_type == "woman": + context["body_phrase"] = character_profile_policy.body_phrase(slot_body, context.get("figure", "")) + else: + context["body_phrase"] = f"{slot_body} figure" + if body_phrase: + context["body_phrase"] = body_phrase + skin_value = character_policy.slot_value(slot.get("skin")) + if skin_value: + context["skin"] = skin_value + eyes_value = character_policy.slot_value(slot.get("eyes")) + if not eyes_value: + eyes_value = character_policy.eye_phrase_from_key(character_policy.characteristic_choice(characteristics, "eyes", appearance_rng)) + if eyes_value: + context["eyes"] = eyes_value + hair_value = character_policy.slot_value(slot.get("hair")) + if hair_value: + context["hair"] = hair_value + else: + hair_descriptor = hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng) + if hair_descriptor: + context["hair"] = hair_descriptor + context["descriptor_detail"] = character_policy.normalize_descriptor_detail(slot.get("descriptor_detail")) + context["presence_mode"] = character_policy.normalize_presence_mode(slot.get("presence_mode"), subject_type) + context["expression_enabled"] = character_slot_policy.slot_expression_enabled(slot) + expression_intensity = character_slot_policy.slot_expression_intensity(slot) + if expression_intensity is not None: + context["expression_intensity"] = expression_intensity + context["subject_type"] = subject_type + context["subject"] = subject_type + context["subject_phrase"] = subject_type + return context + + +def character_context_for_label( + label: str, + label_map: dict[str, dict[str, Any]], + rng: random.Random, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, +) -> tuple[dict[str, Any], dict[str, Any] | None]: + subject_type = "man" if label.startswith("Man ") else "woman" + slot = label_map.get(label) + if slot: + return context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot + return appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None + + +def apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + for key in ( + "subject_type", + "subject", + "subject_phrase", + "age", + "body", + "body_phrase", + "skin", + "hair", + "eyes", + "figure", + "descriptor_detail", + "presence_mode", + "expression_enabled", + "expression_intensity", + ): + value = context.get(key) + if value is not None and value != "": + row[key] = value + if context.get("age"): + row["age_band"] = context["age"] + return row + + +def row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]: + slots = character_slot_policy.parse_character_cast(character_slot) + if not slots: + return {} + slot = slots[-1] + if character_slot_policy.slot_seed(slot) >= 0: + subject_type = str(slot.get("subject_type") or "woman") + return context_from_character_slot( + random.Random(seed_policy.row_seed(character_slot_policy.slot_seed(slot), 1, 719)), + slot, + subject_type, + "any", + "curvy", + False, + False, + ) + return slot diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 05149ae..9391740 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -144,8 +144,11 @@ Already isolated: - 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. + `prompt_builder.py` keeps public delegate wrappers. +- generation-time subject appearance selection, normalized-slot context + resolution, slot hair/outfit/clothing selection, character-context row + application, and character-slot-to-profile-row conversion live in + `character_appearance.py`; `prompt_builder.py` keeps public delegate wrappers. - 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 bb1f895..02c4176 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -72,6 +72,7 @@ Core helper ownership: | `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. | | `cast_context.py` | Generation-time cast count phrases, configured-cast context metadata, character-slot label assignment, cast-summary wording, scene-kind labels, and couple count normalization. | | `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | +| `character_appearance.py` | Generation-time subject appearance selection, normalized-slot context resolution, slot hair/outfit/clothing selection, character-context row application, and character-slot-to-profile-row conversion. | | `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. | @@ -390,9 +391,9 @@ Important behavior: Edit targets: -- Character slot JSON/parsing/summary: `character_slot.py`; appearance field - generation: `_context_from_character_slot`, `_character_context_for_label`; - character-slot label assignment: +- Character slot JSON/parsing/summary: `character_slot.py`; generation-time + appearance field resolution: `character_appearance.py`; 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/prompt_builder.py b/prompt_builder.py index 208c078..11090c2 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -26,6 +26,7 @@ try: from . import camera_config as camera_policy from . import cast_context as cast_context_policy from . import category_template_metadata as item_template_policy + from . import character_appearance as character_appearance_policy from . import character_config as character_policy from . import character_profile as character_profile_policy from . import character_slot as character_slot_policy @@ -71,6 +72,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import camera_config as camera_policy import cast_context as cast_context_policy import category_template_metadata as item_template_policy + import character_appearance as character_appearance_policy import character_config as character_policy import character_profile as character_profile_policy import character_slot as character_slot_policy @@ -2117,51 +2119,7 @@ def _hair_phrase_from_parts(color_key: str, length_key: str, style_key: str) -> def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str: - hair_config = _parse_hair_config(slot.get("hair_config")) - color_choice = _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES) - length_choice = _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES) - style_choice = _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES) - color_options = hair_config.get("colors") or [] - length_options = hair_config.get("lengths") or [] - style_options = hair_config.get("styles") or [] - if ( - color_choice == "random" - and length_choice == "random" - and style_choice == "random" - and not color_options - and not length_options - and not style_options - ): - return "" - if color_choice != "random": - color_key = color_choice - elif color_options: - color_key = g.choose(rng, color_options) - else: - color_key = _infer_hair_color_key(base_hair) - - if length_choice != "random": - length_key = length_choice - elif length_options: - length_key = g.choose(rng, length_options) - else: - length_key = _infer_hair_length_key(base_hair) - - if style_choice != "random": - style_key = style_choice - elif style_options: - style_key = g.choose(rng, style_options) - else: - style_key = _infer_hair_style_key(base_hair) - if color_key == "random": - color_key = _choose_hair_key(rng, CHARACTER_HAIR_COLOR_CHOICES) - if length_key == "random": - length_key = _choose_hair_key(rng, CHARACTER_HAIR_LENGTH_CHOICES) - if style_key == "random": - style_key = _choose_hair_key(rng, CHARACTER_HAIR_STYLE_CHOICES) - if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"): - style_key = g.choose(rng, ["ponytail", "braid", "bun", "messy_bun"]) - return _hair_phrase_from_parts(color_key, length_key, style_key) + return character_appearance_policy.hair_descriptor_from_slot(base_hair, slot, rng) def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]: @@ -2272,25 +2230,11 @@ def _pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str: def _slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: - if not slot: - return "" - outfit = _slot_value(slot.get("softcore_outfit")) - if outfit: - return outfit - if rng is None: - return "" - return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "softcore_outfits", rng) + return character_appearance_policy.slot_softcore_outfit(slot, rng) def _slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str: - if not slot: - return "" - clothing = _slot_value(slot.get("hardcore_clothing")) - if clothing: - return clothing - if rng is None: - return "" - return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "hardcore_clothing", rng) + return character_appearance_policy.slot_hardcore_clothing(slot, rng) def _context_from_character_slot( @@ -2302,63 +2246,16 @@ def _context_from_character_slot( no_plus_women: bool, no_black: bool, ) -> dict[str, str]: - slot_ethnicity = _slot_value(slot.get("ethnicity")) - slot_body = _slot_value(slot.get("body")) - effective_ethnicity = slot_ethnicity or ethnicity - effective_figure = _slot_effective_figure(slot, subject_type, figure) - effective_no_plus = bool(no_plus_women) and not slot_body - effective_no_black = bool(no_black) and not slot_ethnicity - appearance_rng = _slot_context_rng(slot, rng) - context = _appearance_for_subject( - appearance_rng, + return character_appearance_policy.context_from_character_slot( + rng, + slot, subject_type, - effective_ethnicity, - effective_figure, - effective_no_plus, - effective_no_black, + ethnicity, + figure, + no_plus_women, + no_black, ) - characteristics = _parse_characteristics_config(slot.get("characteristics")) - age = _slot_value(slot.get("age")) or _characteristic_choice(characteristics, "ages", appearance_rng) - body_phrase = _slot_value(slot.get("body_phrase")) - if not slot_body: - slot_body = _characteristic_choice(characteristics, "bodies", appearance_rng) - if age: - context["age"] = age - if slot_body: - context["body"] = slot_body - if subject_type == "woman": - context["body_phrase"] = _body_phrase(slot_body, context.get("figure", "")) - else: - context["body_phrase"] = f"{slot_body} figure" - if body_phrase: - context["body_phrase"] = body_phrase - skin_value = _slot_value(slot.get("skin")) - if skin_value: - context["skin"] = skin_value - eyes_value = _slot_value(slot.get("eyes")) - if not eyes_value: - eyes_value = _eye_phrase_from_key(_characteristic_choice(characteristics, "eyes", appearance_rng)) - if eyes_value: - context["eyes"] = eyes_value - hair_value = _slot_value(slot.get("hair")) - if hair_value: - context["hair"] = hair_value - else: - hair_descriptor = _hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng) - if hair_descriptor: - context["hair"] = hair_descriptor - context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail")) - context["presence_mode"] = _normalize_presence_mode(slot.get("presence_mode"), subject_type) - context["expression_enabled"] = _slot_expression_enabled(slot) - expression_intensity = _slot_expression_intensity(slot) - if expression_intensity is not None: - context["expression_intensity"] = expression_intensity - context["subject_type"] = subject_type - context["subject"] = subject_type - context["subject_phrase"] = subject_type - return context - def _character_context_for_label( label: str, @@ -2369,36 +2266,19 @@ def _character_context_for_label( no_plus_women: bool, no_black: bool, ) -> tuple[dict[str, str], dict[str, Any] | None]: - subject_type = "man" if label.startswith("Man ") else "woman" - slot = label_map.get(label) - if slot: - return _context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot - return _appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None + return character_appearance_policy.character_context_for_label( + label, + label_map, + rng, + ethnicity, + figure, + no_plus_women, + no_black, + ) def _apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: - for key in ( - "subject_type", - "subject", - "subject_phrase", - "age", - "body", - "body_phrase", - "skin", - "hair", - "eyes", - "figure", - "descriptor_detail", - "presence_mode", - "expression_enabled", - "expression_intensity", - ): - value = context.get(key) - if value is not None and value != "": - row[key] = value - if context.get("age"): - row["age_band"] = context["age"] - return row + return character_appearance_policy.apply_character_context_to_row(row, context) def _cast_descriptor_entries( @@ -2439,22 +2319,7 @@ def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> di def _row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]: - slots = _parse_character_cast(character_slot) - if not slots: - return {} - slot = slots[-1] - if _slot_seed(slot) >= 0: - subject_type = str(slot.get("subject_type") or "woman") - return _context_from_character_slot( - random.Random(_row_seed(_slot_seed(slot), 1, 719)), - slot, - subject_type, - "any", - "curvy", - False, - False, - ) - return slot + return character_appearance_policy.row_from_character_slot(character_slot) def _character_profile_descriptor(profile: dict[str, Any]) -> str: @@ -2591,38 +2456,14 @@ def _appearance_for_subject( no_plus_women: bool, no_black: bool, ) -> dict[str, str]: - if subject_type == "single_any": - subject_type = "woman" if rng.random() < 0.82 else "man" - - if subject_type == "man": - men_ethnicity = ethnicity if ethnicity else "any" - subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity)) - return { - "subject_type": "man", - "subject": subject, - "subject_phrase": subject, - "age": age, - "body": body, - "skin": skin, - "hair": hair, - "eyes": eyes, - "body_phrase": f"{body} figure", - } - - subject, age, body, skin, hair, eyes = g.choose_woman(rng, ethnicity, no_plus_women, no_black) - figure_note = g.choose(rng, g.figure_pool(figure)) - return { - "subject_type": "woman", - "subject": subject, - "subject_phrase": subject, - "age": age, - "body": body, - "skin": skin, - "hair": hair, - "eyes": eyes, - "body_phrase": _body_phrase(body, figure_note), - "figure": figure_note, - } + return character_appearance_policy.appearance_for_subject( + rng, + subject_type, + ethnicity, + figure, + no_plus_women, + no_black, + ) def _count_phrase(count: int, singular: str, plural: str) -> str: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 4115856..e41f2a7 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -28,6 +28,7 @@ import caption_naturalizer # noqa: E402 import caption_policy # noqa: E402 import cast_context # noqa: E402 import category_template_metadata # noqa: E402 +import character_appearance # noqa: E402 import character_config # noqa: E402 import character_profile # noqa: E402 import character_slot # noqa: E402 @@ -935,6 +936,23 @@ def smoke_character_config_policy() -> None: == character_slot.slot_effective_figure({"slot_seed": 123, "figure": "random"}, "woman", "curvy"), "Prompt builder seeded slot figure should delegate to character_slot", ) + _expect( + pb._appearance_for_subject(random.Random(9), "woman", "western_european", "balanced", False, False) + == character_appearance.appearance_for_subject(random.Random(9), "woman", "western_european", "balanced", False, False), + "Prompt builder appearance selection should delegate to character_appearance", + ) + _expect( + pb._context_from_character_slot(random.Random(11), slot, "man", "any", "curvy", False, False) + == character_appearance.context_from_character_slot(random.Random(11), slot, "man", "any", "curvy", False, False), + "Prompt builder slot context should delegate to character_appearance", + ) + _expect( + pb._row_from_character_slot(slot_result["character_slot"]) + == character_appearance.row_from_character_slot(slot_result["character_slot"]), + "Prompt builder slot row conversion should delegate to character_appearance", + ) + row = character_appearance.apply_character_context_to_row({}, {"age": "44-year-old adult", "body": "stocky"}) + _expect(row.get("age_band") == "44-year-old adult" and row.get("body") == "stocky", "Character context row application changed") def smoke_character_profile_policy() -> None: