diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 8334936..f642b60 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -229,9 +229,9 @@ Already isolated: override resolution, Woman A slot context application, soft outfit/pose overrides, POV row fields, and hardcore row creation. - pair-level cast/display context lives in `pair_cast.py`, including descriptor - prose, shared descriptors, cast-label cleanup, same-cast softcore descriptor - text, partner styling, platform and level labels, softcore cast presence text, - and hard cast summary text. + prose, descriptor-entry assembly, shared descriptors, cast-label cleanup, + same-cast softcore descriptor text, partner styling, platform and level + labels, softcore cast presence text, and hard cast summary text. - pair-level camera routing lives in `pair_camera.py`, including soft/hard camera config selection, same-as-softcore mode, camera-detail override, same-room hard scene continuity, camera-aware composition mutation, POV camera diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 9d93b5c..2cedb31 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -82,7 +82,7 @@ Core helper ownership: | `hardcore_position_config.py` | Hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, and category/template/axis filtering. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, hardcore cast count policy, and hardcore detail-density directives. | | `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | -| `pair_cast.py` | Insta/OF descriptor prose, shared descriptors, cast-label cleanup, same-cast softcore descriptor text, partner styling selection, cast-summary wording, platform/level labels, softcore cast presence text, and hard cast summary text. | +| `pair_cast.py` | Insta/OF descriptor prose, descriptor-entry assembly, shared descriptors, cast-label cleanup, same-cast softcore descriptor text, partner styling selection, cast-summary wording, platform/level labels, softcore cast presence text, and hard cast summary text. | | `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | | `pair_clothing.py` | Insta/OF clothing sentence formatting, body-exposure scene cleanup, hardcore clothing continuity, action-aware body-access flags, conflicting outfit-piece cleanup, configured/default visible-person clothing, and final root clothing-state assembly. | | `pair_output.py` | Insta/OF final pair prompts, trigger preservation, negative prompts, captions, and root pair metadata assembly. | @@ -390,7 +390,8 @@ Important behavior: Edit targets: - Appearance field generation: `_context_from_character_slot`, - `_character_context_for_label`, `_cast_descriptor_entries`. + `_character_context_for_label`; pair cast descriptor entry assembly: + `pair_cast.cast_descriptor_entries`. - Profile save/load: `SxCPCharacterProfileSave`, `SxCPCharacterProfileLoad`, profile policy in `character_profile.py`, and `web/profile_buttons.js`. diff --git a/pair_cast.py b/pair_cast.py index 8ab64a3..8ee9c3c 100644 --- a/pair_cast.py +++ b/pair_cast.py @@ -14,9 +14,15 @@ except ImportError: # Allows local smoke tests with top-level imports. import pair_options -CastDescriptors = Callable[..., list[str]] AxisRng = Callable[[dict[str, int], str, int, int], Any] Choose = Callable[[Any, list[str]], str] +CharacterContextForLabel = Callable[ + [str, dict[str, dict[str, Any]], Any, str, str, bool, bool], + tuple[dict[str, Any], dict[str, Any] | None], +] +CharacterSlotLabelMap = Callable[[list[dict[str, Any]]], dict[str, dict[str, Any]]] +ParseCharacterCast = Callable[[str | dict[str, Any] | list[Any] | None], list[dict[str, Any]]] +SlotIsPov = Callable[[dict[str, Any] | None], bool] SlotSoftcoreOutfit = Callable[[dict[str, Any] | None, Any], str] @@ -53,6 +59,98 @@ def prompt_cast_descriptors(text: str) -> str: return str(text or "").replace("Woman A / primary creator:", "Woman A:") +def cast_descriptor_entries_from_slots( + *, + seed_config: dict[str, int], + seed: int, + row_number: int, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + women_count: int, + men_count: int, + character_slots: list[dict[str, Any]], + character_slot_map: dict[str, dict[str, Any]], + primary_descriptor: str = "", + axis_rng: AxisRng, + character_context_for_label: CharacterContextForLabel, + slot_is_pov: SlotIsPov, +) -> tuple[list[str], list[dict[str, Any]]]: + rng = axis_rng(seed_config, "person", seed, row_number + 997) + descriptors: list[str] = [] + for index in range(max(0, women_count)): + label = f"Woman {chr(ord('A') + index)}" + if index == 0 and primary_descriptor: + descriptors.append(f"Woman A / primary creator: {primary_descriptor}") + continue + context, _slot = character_context_for_label( + label, + character_slot_map, + rng, + ethnicity, + figure, + no_plus_women, + no_black, + ) + descriptors.append(f"{label}: {insta_descriptor_from_context(context)}") + for index in range(max(0, men_count)): + label = f"Man {chr(ord('A') + index)}" + if slot_is_pov(character_slot_map.get(label)): + continue + context, _slot = character_context_for_label( + label, + character_slot_map, + rng, + ethnicity, + figure, + no_plus_women, + no_black, + ) + descriptors.append(f"{label}: {insta_descriptor_from_context(context)}") + return descriptors, character_slots + + +def cast_descriptor_entries( + *, + seed_config: dict[str, int], + seed: int, + row_number: int, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + women_count: int, + men_count: int, + character_cast: str | dict[str, Any] | list[Any] | None = "", + primary_descriptor: str = "", + parse_character_cast: ParseCharacterCast, + character_slot_label_map: CharacterSlotLabelMap, + axis_rng: AxisRng, + character_context_for_label: CharacterContextForLabel, + slot_is_pov: SlotIsPov, +) -> tuple[list[str], list[dict[str, Any]]]: + slots = parse_character_cast(character_cast) + label_map = character_slot_label_map(slots) + return cast_descriptor_entries_from_slots( + seed_config=seed_config, + seed=seed, + row_number=row_number, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + women_count=women_count, + men_count=men_count, + character_slots=slots, + character_slot_map=label_map, + primary_descriptor=primary_descriptor, + axis_rng=axis_rng, + character_context_for_label=character_context_for_label, + slot_is_pov=slot_is_pov, + ) + + def softcore_partner_styling( *, seed_config: dict[str, int], @@ -117,24 +215,29 @@ def resolve_insta_pair_cast_context( platform_styles: dict[str, str], soft_levels: dict[str, str], hardcore_levels: dict[str, str], - build_cast_descriptors: CastDescriptors, axis_rng: AxisRng, + character_context_for_label: CharacterContextForLabel, + slot_is_pov: SlotIsPov, choose: Choose, slot_softcore_outfit: SlotSoftcoreOutfit, ) -> dict[str, Any]: descriptor = insta_descriptor_from_row(soft_row) - cast_descriptors = build_cast_descriptors( - descriptor, - parsed_seed_config, - seed, - row_number, - ethnicity, - figure, - no_plus_women, - no_black, - hard_women_count, - hard_men_count, - character_slots, + cast_descriptors, _descriptor_slots = cast_descriptor_entries_from_slots( + seed_config=parsed_seed_config, + seed=seed, + row_number=row_number, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + women_count=hard_women_count, + men_count=hard_men_count, + character_slots=character_slots, + character_slot_map=character_slot_map, + primary_descriptor=descriptor, + axis_rng=axis_rng, + character_context_for_label=character_context_for_label, + slot_is_pov=slot_is_pov, ) cast_descriptor_text = prompt_cast_descriptors("; ".join(cast_descriptors)) same_softcore_cast = options["softcore_cast"] == "same_as_hardcore" diff --git a/prompt_builder.py b/prompt_builder.py index 80449ce..320acc0 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -2621,24 +2621,24 @@ def _cast_descriptor_entries( character_cast: str | dict[str, Any] | list[Any] | None = "", primary_descriptor: str = "", ) -> tuple[list[str], list[dict[str, Any]]]: - slots = _parse_character_cast(character_cast) - label_map = _character_slot_label_map(slots) - rng = _axis_rng(seed_config, "person", seed, row_number + 997) - descriptors: list[str] = [] - for index in range(max(0, women_count)): - label = f"Woman {chr(ord('A') + index)}" - if index == 0 and primary_descriptor: - descriptors.append(f"Woman A / primary creator: {primary_descriptor}") - continue - context, _slot = _character_context_for_label(label, label_map, rng, ethnicity, figure, no_plus_women, no_black) - descriptors.append(f"{label}: {_insta_of_descriptor_from_context(context)}") - for index in range(max(0, men_count)): - label = f"Man {chr(ord('A') + index)}" - if _slot_is_pov(label_map.get(label)): - continue - context, _slot = _character_context_for_label(label, label_map, rng, ethnicity, figure, no_plus_women, no_black) - descriptors.append(f"{label}: {_insta_of_descriptor_from_context(context)}") - return descriptors, slots + return pair_cast.cast_descriptor_entries( + seed_config=seed_config, + seed=seed, + row_number=row_number, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + women_count=women_count, + men_count=men_count, + character_cast=character_cast, + primary_descriptor=primary_descriptor, + parse_character_cast=_parse_character_cast, + character_slot_label_map=_character_slot_label_map, + axis_rng=_axis_rng, + character_context_for_label=_character_context_for_label, + slot_is_pov=_slot_is_pov, + ) def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]: @@ -3826,35 +3826,6 @@ def _insta_of_descriptor_from_context(context: dict[str, Any]) -> str: return pair_cast.insta_descriptor_from_context(context) -def _insta_of_cast_descriptors( - primary_descriptor: str, - seed_config: dict[str, int], - seed: int, - row_number: int, - ethnicity: str, - figure: str, - no_plus_women: bool, - no_black: bool, - women_count: int, - men_count: int, - character_cast: str | dict[str, Any] | list[Any] | None = "", -) -> list[str]: - descriptors, _slots = _cast_descriptor_entries( - seed_config, - seed, - row_number, - ethnicity, - figure, - no_plus_women, - no_black, - women_count, - men_count, - character_cast, - primary_descriptor=primary_descriptor, - ) - return descriptors - - def _insta_of_prompt_cast_descriptors(text: str) -> str: return pair_cast.prompt_cast_descriptors(text) @@ -3973,8 +3944,9 @@ def build_insta_of_pair( platform_styles=INSTA_OF_PLATFORM_STYLES, soft_levels=INSTA_OF_SOFT_LEVELS, hardcore_levels=INSTA_OF_HARDCORE_LEVELS, - build_cast_descriptors=_insta_of_cast_descriptors, axis_rng=_axis_rng, + character_context_for_label=_character_context_for_label, + slot_is_pov=_slot_is_pov, choose=g.choose, slot_softcore_outfit=_slot_softcore_outfit, ) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 971cb47..21a0663 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -1732,6 +1732,49 @@ def smoke_pair_options_policy() -> None: pair_cast.prompt_cast_descriptors("Woman A / primary creator: descriptor") == "Woman A: descriptor", "Pair cast prompt descriptor label cleanup changed", ) + + def _fake_character_context( + 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 = "man" if label.startswith("Man ") else "woman" + age = "40-year-old adult" if subject == "man" else "30-year-old adult" + return {"subject_type": subject, "age": age, "body_phrase": f"{label} body"}, label_map.get(label) + + descriptor_entries, descriptor_slots = pair_cast.cast_descriptor_entries_from_slots( + seed_config={}, + seed=1, + row_number=1, + ethnicity="any", + figure="any", + no_plus_women=False, + no_black=False, + women_count=2, + men_count=1, + character_slots=[{"subject_type": "man", "presence_mode": "pov"}], + character_slot_map={"Man A": {"subject_type": "man", "presence_mode": "pov"}}, + primary_descriptor="primary descriptor", + axis_rng=lambda _config, _axis, seed_value, row_value: random.Random(seed_value + row_value), + character_context_for_label=_fake_character_context, + slot_is_pov=lambda slot: bool(slot and slot.get("presence_mode") == "pov"), + ) + _expect( + descriptor_entries + == [ + "Woman A / primary creator: primary descriptor", + "Woman B: 30-year-old adult woman, Woman B body", + ], + "Pair cast descriptor entries should keep primary label and skip POV men", + ) + _expect( + descriptor_slots == [{"subject_type": "man", "presence_mode": "pov"}], + "Pair cast descriptor entries should return the source slots", + ) partner_styling = pair_cast.softcore_partner_styling( seed_config={}, seed=1,