diff --git a/cast_context.py b/cast_context.py new file mode 100644 index 0000000..b9c28e5 --- /dev/null +++ b/cast_context.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any, Callable + + +Choose = Callable[[Any, list[tuple[str, str, str]]], tuple[str, str, str]] + + +def count_phrase(count: int, singular: str, plural: str) -> str: + words = { + 0: "no", + 1: "one", + 2: "two", + 3: "three", + 4: "four", + 5: "five", + 6: "six", + 7: "seven", + 8: "eight", + 9: "nine", + 10: "ten", + 11: "eleven", + 12: "twelve", + } + label = singular if count == 1 else plural + return f"{words.get(count, str(count))} {label}" + + +def cast_summary_phrase(women_count: int, men_count: int) -> str: + women_count = max(0, int(women_count)) + men_count = max(0, int(men_count)) + if women_count + men_count == 0: + women_count = 1 + person_count = women_count + men_count + women_label = "woman" if women_count == 1 else "women" + men_label = "man" if men_count == 1 else "men" + return f"{women_count} {women_label}, {men_count} {men_label}, {person_count} total adults" + + +def configured_cast_context(women_count: int, men_count: int) -> dict[str, str]: + women_count = max(0, int(women_count)) + men_count = max(0, int(men_count)) + if women_count + men_count == 0: + women_count = 1 + parts = [] + if women_count: + parts.append(count_phrase(women_count, "adult woman", "adult women")) + if men_count: + parts.append(count_phrase(men_count, "adult man", "adult men")) + subject_phrase = parts[0] if len(parts) == 1 else f"{parts[0]} and {parts[1]}" + person_count = women_count + men_count + if person_count == 1: + scene_kind = "solo adult sexual pose" + elif person_count == 2: + scene_kind = "adult couple sex scene" + elif person_count == 3: + scene_kind = "adult threesome sex scene" + else: + scene_kind = "adult group sex scene" + return { + "subject_type": "configured_cast", + "subject": f"{women_count}w_{men_count}m_sex_scene", + "subject_phrase": subject_phrase, + "age": "21+ adults", + "body": "varied", + "skin": "", + "hair": "", + "eyes": "", + "body_phrase": "varied adult bodies", + "women_count": str(women_count), + "men_count": str(men_count), + "person_count": str(person_count), + "cast_summary": cast_summary_phrase(women_count, men_count), + "scene_kind": scene_kind, + } + + +def couple_type_from_counts( + rng: Any, + women_count: int, + men_count: int, + *, + choose: Choose, + couple_types: list[tuple[str, str, str]], +) -> tuple[str, str, str, int, int]: + women_count = max(0, int(women_count)) + men_count = max(0, int(men_count)) + if women_count >= 2 and men_count == 0: + return "two women", "two women", "close affectionate couple pose", 2, 0 + if men_count >= 2 and women_count == 0: + return "two men", "two men", "relaxed romantic couple pose", 0, 2 + if women_count >= 1 and men_count >= 1: + return "woman and man", "a woman and a man", "playful date-night pose", 1, 1 + + primary_subject, subject_phrase, pose = choose(rng, couple_types) + if primary_subject == "two women": + return primary_subject, subject_phrase, pose, 2, 0 + if primary_subject == "two men": + return primary_subject, subject_phrase, pose, 0, 2 + return primary_subject, subject_phrase, pose, 1, 1 diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 982da1e..44c8964 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -129,6 +129,10 @@ Already isolated: - category/cast route preset schemas, config JSON builders, choice lists, and parsers live in `category_cast_config.py`; `prompt_builder.py` keeps public delegate wrappers for existing nodes and tests. +- generation-time cast count phrases, configured-cast context metadata, + scene-kind labels, cast-summary wording, and couple count normalization live + in `cast_context.py`; `prompt_builder.py` keeps delegate wrappers where + existing generation paths still call the old helper names. - ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter parsing, and ethnicity normalization live in `filter_config.py`; character routes and builder filters use `prompt_builder.py` delegate wrappers. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 325ba58..a713139 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -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_template_metadata.py` | Object-style item-template metadata extraction, action/position family normalization, position-key normalization, key merging, and audit validation errors. | | `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, 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_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. | diff --git a/pair_cast.py b/pair_cast.py index 862d4b2..6dc8914 100644 --- a/pair_cast.py +++ b/pair_cast.py @@ -3,9 +3,11 @@ from __future__ import annotations from typing import Any, Callable try: + from . import cast_context as cast_context_policy from . import pair_clothing from . import pair_options except ImportError: # Allows local smoke tests with top-level imports. + import cast_context as cast_context_policy import pair_clothing import pair_options @@ -18,14 +20,7 @@ SlotSoftcoreOutfit = Callable[[dict[str, Any] | None, Any], str] def cast_summary_phrase(women_count: int, men_count: int) -> str: - women_count = max(0, int(women_count)) - men_count = max(0, int(men_count)) - if women_count + men_count == 0: - women_count = 1 - person_count = women_count + men_count - women_label = "woman" if women_count == 1 else "women" - men_label = "man" if men_count == 1 else "men" - return f"{women_count} {women_label}, {men_count} {men_label}, {person_count} total adults" + return cast_context_policy.cast_summary_phrase(women_count, men_count) def softcore_partner_styling( diff --git a/prompt_builder.py b/prompt_builder.py index 6ef0925..1ae46b4 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -24,6 +24,7 @@ try: template_list as _template_list, ) 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_config as character_policy from . import character_profile as character_profile_policy @@ -67,6 +68,7 @@ except ImportError: # Allows local smoke tests with `python -c`. template_list as _template_list, ) import camera_config as camera_policy + import cast_context as cast_context_policy import category_template_metadata as item_template_policy import character_config as character_policy import character_profile as character_profile_policy @@ -2831,67 +2833,11 @@ def _appearance_for_subject( def _count_phrase(count: int, singular: str, plural: str) -> str: - words = { - 0: "no", - 1: "one", - 2: "two", - 3: "three", - 4: "four", - 5: "five", - 6: "six", - 7: "seven", - 8: "eight", - 9: "nine", - 10: "ten", - 11: "eleven", - 12: "twelve", - } - label = singular if count == 1 else plural - return f"{words.get(count, str(count))} {label}" + return cast_context_policy.count_phrase(count, singular, plural) def _configured_cast_context(women_count: int, men_count: int) -> dict[str, str]: - women_count = max(0, int(women_count)) - men_count = max(0, int(men_count)) - if women_count + men_count == 0: - women_count = 1 - parts = [] - if women_count: - parts.append(_count_phrase(women_count, "adult woman", "adult women")) - if men_count: - parts.append(_count_phrase(men_count, "adult man", "adult men")) - if len(parts) == 1: - subject_phrase = parts[0] - else: - subject_phrase = f"{parts[0]} and {parts[1]}" - person_count = women_count + men_count - if person_count == 1: - scene_kind = "solo adult sexual pose" - elif person_count == 2: - scene_kind = "adult couple sex scene" - elif person_count == 3: - scene_kind = "adult threesome sex scene" - else: - scene_kind = "adult group sex scene" - women_label = "woman" if women_count == 1 else "women" - men_label = "man" if men_count == 1 else "men" - cast_summary = f"{women_count} {women_label}, {men_count} {men_label}, {person_count} total adults" - return { - "subject_type": "configured_cast", - "subject": f"{women_count}w_{men_count}m_sex_scene", - "subject_phrase": subject_phrase, - "age": "21+ adults", - "body": "varied", - "skin": "", - "hair": "", - "eyes": "", - "body_phrase": "varied adult bodies", - "women_count": str(women_count), - "men_count": str(men_count), - "person_count": str(person_count), - "cast_summary": cast_summary, - "scene_kind": scene_kind, - } + return cast_context_policy.configured_cast_context(women_count, men_count) def _couple_type_from_counts( @@ -2899,21 +2845,13 @@ def _couple_type_from_counts( women_count: int, men_count: int, ) -> tuple[str, str, str, int, int]: - women_count = max(0, int(women_count)) - men_count = max(0, int(men_count)) - if women_count >= 2 and men_count == 0: - return "two women", "two women", "close affectionate couple pose", 2, 0 - if men_count >= 2 and women_count == 0: - return "two men", "two men", "relaxed romantic couple pose", 0, 2 - if women_count >= 1 and men_count >= 1: - return "woman and man", "a woman and a man", "playful date-night pose", 1, 1 - - primary_subject, subject_phrase, pose = g.choose(rng, g.COUPLE_TYPES) - if primary_subject == "two women": - return primary_subject, subject_phrase, pose, 2, 0 - if primary_subject == "two men": - return primary_subject, subject_phrase, pose, 0, 2 - return primary_subject, subject_phrase, pose, 1, 1 + return cast_context_policy.couple_type_from_counts( + rng, + women_count, + men_count, + choose=g.choose, + couple_types=g.COUPLE_TYPES, + ) def _subject_context( diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 67fa265..2f50755 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -26,6 +26,7 @@ if str(ROOT) not in sys.path: import caption_naturalizer # noqa: E402 import caption_policy # noqa: E402 +import cast_context # noqa: E402 import category_template_metadata # noqa: E402 import character_config # noqa: E402 import character_profile # noqa: E402 @@ -658,6 +659,29 @@ def smoke_category_cast_config_policy() -> None: _expect(pb.CAST_PRESETS is category_cast_config.CAST_PRESETS, "Prompt builder cast presets are not delegated") _expect("hardcore_pose" in category_cast_config.category_preset_choices(), "Category preset choices lost hardcore_pose") _expect("custom_counts" in category_cast_config.cast_preset_choices(), "Cast preset choices lost custom_counts") + _expect( + pb._count_phrase(2, "adult woman", "adult women") == cast_context.count_phrase(2, "adult woman", "adult women"), + "Prompt builder count phrase should delegate to cast_context", + ) + configured = cast_context.configured_cast_context(1, 2) + _expect(configured.get("subject_phrase") == "one adult woman and two adult men", "Configured cast subject phrase changed") + _expect(configured.get("cast_summary") == "1 woman, 2 men, 3 total adults", "Configured cast summary changed") + _expect(configured.get("scene_kind") == "adult threesome sex scene", "Configured cast scene kind changed") + _expect( + pb._configured_cast_context(1, 2) == configured, + "Prompt builder configured cast context should delegate to cast_context", + ) + _expect( + cast_context.couple_type_from_counts( + random.Random(1), + 2, + 0, + choose=lambda _rng, pool: pool[0], + couple_types=[("woman and man", "a woman and a man", "fallback pose")], + ) + == ("two women", "two women", "close affectionate couple pose", 2, 0), + "Couple type count override for two women changed", + ) category_config = json.loads(pb.build_category_config_json("hardcore_pose", "Foreplay and teasing")) _expect(category_config.get("category") == "Hardcore sexual poses", "Category config lost hardcore category mapping")