Add typed pair route contracts

This commit is contained in:
2026-06-27 10:49:58 +02:00
parent 2c978c7eab
commit 28612f9d00
7 changed files with 439 additions and 74 deletions
+15 -12
View File
@@ -290,22 +290,25 @@ Already isolated:
policy, plus hardcore detail-density directive text, live in
`pair_options.py`; `prompt_builder.py` keeps public delegate wrappers for
existing nodes and tests.
- soft/hard row creation lives in `pair_rows.py`, including softcore expression
override resolution, Woman A slot context application, soft outfit/pose
overrides, POV row fields, and hardcore row creation.
- soft/hard row creation lives in `pair_rows.py` behind `InstaPairRowsRoute`,
including softcore expression override resolution, Woman A slot context
application, soft outfit/pose overrides, POV row fields, hardcore row
creation, and legacy dict compatibility.
- pair-level cast/display context lives in `pair_cast.py`, including descriptor
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
suppression, and row/root camera metadata synchronization.
- pair-level clothing policy lives in `pair_clothing.py`, including clothing
sentence formatting, body-exposure scene cleanup, action-aware body-access
flags, conflicting outfit-piece cleanup, default visible-men clothing,
character-clothing override handling, hardcore clothing continuity, and final
root clothing-state assembly.
- pair-level camera routing lives in `pair_camera.py` behind
`InstaPairCameraRoute`, including soft/hard camera config selection,
same-as-softcore mode, camera-detail override, same-room hard scene
continuity, camera-aware composition mutation, POV camera suppression,
row/root camera metadata synchronization, and legacy dict compatibility.
- pair-level clothing policy lives in `pair_clothing.py` behind
`HardcorePairClothingRoute`, including clothing sentence formatting,
body-exposure scene cleanup, action-aware body-access flags, conflicting
outfit-piece cleanup, default visible-men clothing, character-clothing
override handling, hardcore clothing continuity, final root clothing-state
assembly, and legacy dict compatibility.
- final pair output assembly lives in `pair_output.py`, including soft/hard
prompt strings, trigger preservation, negatives, captions, and root metadata
shape; the final cleanup step is delegated to `row_normalization.py`.
+3 -3
View File
@@ -96,10 +96,10 @@ Core helper ownership:
| `row_prompt_axes.py` | Row scene/pose/expression/composition axis selection behind `PromptAxesRoute`, compatible-entry filtering, expression-disabled handling, per-character expression promotion, legacy dict compatibility, POV composition adaptation, and pose-category environment sanitizing. |
| `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_rows.py` | Insta/OF soft/hard row creation behind `InstaPairRowsRoute`, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, POV row fields, and legacy dict compatibility. |
| `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_camera.py` | Insta/OF soft/hard camera route resolution behind `InstaPairCameraRoute`, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, synchronized row/root camera metadata, and legacy dict compatibility. |
| `pair_clothing.py` | Insta/OF clothing sentence formatting and hardcore clothing continuity behind `HardcorePairClothingRoute`, body-exposure scene cleanup, action-aware body-access flags, conflicting outfit-piece cleanup, configured/default visible-person clothing, final root clothing-state assembly, and legacy dict compatibility. |
| `pair_output.py` | Insta/OF final pair prompts, trigger preservation, negative prompts, captions, and root pair metadata assembly. |
| `hardcore_role_graphs.py` | Source role graph construction for hardcore configured-cast rows, including POV-aware interaction geometry, called through `row_role_graph.py` for row generation. |
| `hardcore_role_fallback.py` | Solo, same-sex, mixed group fallback, and support-partner role graph wording for configured casts. |
+94 -18
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
@@ -23,7 +24,43 @@ def camera_config_with_detail(
return camera_config
def resolve_insta_pair_camera(
@dataclass(frozen=True)
class InstaPairCameraRoute:
soft_row: dict[str, Any]
hard_row: dict[str, Any]
hard_scene: str
hard_composition: str
soft_camera_config: dict[str, Any]
hard_camera_config: dict[str, Any]
soft_camera_directive: str
hard_camera_directive: str
soft_camera_scene_directive: str
hard_camera_scene_directive: str
soft_camera_scene_sentence: str
hard_camera_scene_sentence: str
soft_camera_sentence: str
hard_camera_sentence: str
def as_dict(self) -> dict[str, Any]:
return {
"soft_row": self.soft_row,
"hard_row": self.hard_row,
"hard_scene": self.hard_scene,
"hard_composition": self.hard_composition,
"soft_camera_config": dict(self.soft_camera_config),
"hard_camera_config": dict(self.hard_camera_config),
"soft_camera_directive": self.soft_camera_directive,
"hard_camera_directive": self.hard_camera_directive,
"soft_camera_scene_directive": self.soft_camera_scene_directive,
"hard_camera_scene_directive": self.hard_camera_scene_directive,
"soft_camera_scene_sentence": self.soft_camera_scene_sentence,
"hard_camera_scene_sentence": self.hard_camera_scene_sentence,
"soft_camera_sentence": self.soft_camera_sentence,
"hard_camera_sentence": self.hard_camera_sentence,
}
def resolve_insta_pair_camera_result(
*,
soft_row: dict[str, Any],
hard_row: dict[str, Any],
@@ -41,7 +78,7 @@ def resolve_insta_pair_camera(
contextual_composition_prompt: CompositionPrompt,
composition_prompt: Callable[[Any], str],
camera_scene_directive_for_context: CameraSceneDirective,
) -> dict[str, Any]:
) -> InstaPairCameraRoute:
hard_camera_mode = str(options["hardcore_camera_mode"])
soft_camera_source = softcore_camera_config or camera_config
hard_camera_source = hardcore_camera_config or camera_config
@@ -108,19 +145,58 @@ def resolve_insta_pair_camera(
hard_row["camera_directive"] = hard_camera_directive
hard_row["camera_scene_directive"] = hard_camera_scene_directive
return {
"soft_row": soft_row,
"hard_row": hard_row,
"hard_scene": hard_scene,
"hard_composition": hard_composition,
"soft_camera_config": soft_camera_config_dict,
"hard_camera_config": hard_camera_config_dict,
"soft_camera_directive": soft_camera_directive,
"hard_camera_directive": hard_camera_directive,
"soft_camera_scene_directive": soft_camera_scene_directive,
"hard_camera_scene_directive": hard_camera_scene_directive,
"soft_camera_scene_sentence": f"{soft_camera_scene_directive} " if soft_camera_scene_directive else "",
"hard_camera_scene_sentence": f"{hard_camera_scene_directive} " if hard_camera_scene_directive else "",
"soft_camera_sentence": f"Camera control: {soft_camera_directive} " if soft_camera_directive else "",
"hard_camera_sentence": f"Camera control: {hard_camera_directive} " if hard_camera_directive else "",
}
return InstaPairCameraRoute(
soft_row=soft_row,
hard_row=hard_row,
hard_scene=hard_scene,
hard_composition=hard_composition,
soft_camera_config=soft_camera_config_dict,
hard_camera_config=hard_camera_config_dict,
soft_camera_directive=soft_camera_directive,
hard_camera_directive=hard_camera_directive,
soft_camera_scene_directive=soft_camera_scene_directive,
hard_camera_scene_directive=hard_camera_scene_directive,
soft_camera_scene_sentence=f"{soft_camera_scene_directive} " if soft_camera_scene_directive else "",
hard_camera_scene_sentence=f"{hard_camera_scene_directive} " if hard_camera_scene_directive else "",
soft_camera_sentence=f"Camera control: {soft_camera_directive} " if soft_camera_directive else "",
hard_camera_sentence=f"Camera control: {hard_camera_directive} " if hard_camera_directive else "",
)
def resolve_insta_pair_camera(
*,
soft_row: dict[str, Any],
hard_row: dict[str, Any],
options: dict[str, Any],
camera_config: str | dict[str, Any] | None,
softcore_camera_config: str | dict[str, Any] | None,
hardcore_camera_config: str | dict[str, Any] | None,
hard_women_count: int,
hard_men_count: int,
pov_character_labels: list[str],
camera_detail_choices: list[str] | tuple[str, ...],
camera_config_with_mode: CameraConfigWithMode,
camera_directive: CameraDirective,
apply_contextual_composition: ApplyComposition,
contextual_composition_prompt: CompositionPrompt,
composition_prompt: Callable[[Any], str],
camera_scene_directive_for_context: CameraSceneDirective,
) -> dict[str, Any]:
return resolve_insta_pair_camera_result(
soft_row=soft_row,
hard_row=hard_row,
options=options,
camera_config=camera_config,
softcore_camera_config=softcore_camera_config,
hardcore_camera_config=hardcore_camera_config,
hard_women_count=hard_women_count,
hard_men_count=hard_men_count,
pov_character_labels=pov_character_labels,
camera_detail_choices=camera_detail_choices,
camera_config_with_mode=camera_config_with_mode,
camera_directive=camera_directive,
apply_contextual_composition=apply_contextual_composition,
contextual_composition_prompt=contextual_composition_prompt,
composition_prompt=composition_prompt,
camera_scene_directive_for_context=camera_scene_directive_for_context,
).as_dict()
+56 -10
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any, Callable
@@ -373,7 +374,27 @@ def default_man_hardcore_clothing_entries(
return entries
def resolve_hardcore_pair_clothing(
@dataclass(frozen=True)
class HardcorePairClothingRoute:
access_flags: dict[str, bool]
woman_access: str
default_man_hardcore_clothing: list[str]
hardcore_clothing_state: str
hardcore_clothing_sentence: str
requires_body_exposure_scene: bool
def as_dict(self) -> dict[str, Any]:
return {
"access_flags": dict(self.access_flags),
"woman_access": self.woman_access,
"default_man_hardcore_clothing": list(self.default_man_hardcore_clothing),
"hardcore_clothing_state": self.hardcore_clothing_state,
"hardcore_clothing_sentence": self.hardcore_clothing_sentence,
"requires_body_exposure_scene": self.requires_body_exposure_scene,
}
def resolve_hardcore_pair_clothing_result(
*,
hard_row: dict[str, Any],
mode: str,
@@ -384,7 +405,7 @@ def resolve_hardcore_pair_clothing(
rng: Any,
continuity_map: dict[str, str],
choose: Callable[[Any, list[str]], str],
) -> dict[str, Any]:
) -> HardcorePairClothingRoute:
access_flags = hardcore_row_access_flags(hard_row)
woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else ""
default_man_entries = default_man_hardcore_clothing_entries(
@@ -412,14 +433,39 @@ def resolve_hardcore_pair_clothing(
if str(part or "").strip()
]
hard_clothing_state = "; ".join(hard_clothing_parts)
return {
"access_flags": access_flags,
"woman_access": woman_access,
"default_man_hardcore_clothing": default_man_entries,
"hardcore_clothing_state": hard_clothing_state,
"hardcore_clothing_sentence": f"{hard_clothing_state}. " if hard_clothing_state else "",
"requires_body_exposure_scene": (
return HardcorePairClothingRoute(
access_flags=access_flags,
woman_access=woman_access,
default_man_hardcore_clothing=default_man_entries,
hardcore_clothing_state=hard_clothing_state,
hardcore_clothing_sentence=f"{hard_clothing_state}. " if hard_clothing_state else "",
requires_body_exposure_scene=(
"body is fully exposed" in hard_clothing_state.lower()
or "bare skin unobstructed" in hard_clothing_state.lower()
),
}
)
def resolve_hardcore_pair_clothing(
*,
hard_row: dict[str, Any],
mode: str,
softcore_outfit: str,
character_hardcore_clothing_entries: list[str],
men_count: int,
pov_labels: list[str] | None,
rng: Any,
continuity_map: dict[str, str],
choose: Callable[[Any, list[str]], str],
) -> dict[str, Any]:
return resolve_hardcore_pair_clothing_result(
hard_row=hard_row,
mode=mode,
softcore_outfit=softcore_outfit,
character_hardcore_clothing_entries=character_hardcore_clothing_entries,
men_count=men_count,
pov_labels=pov_labels,
rng=rng,
continuity_map=continuity_map,
choose=choose,
).as_dict()
+102 -7
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
try:
@@ -12,7 +13,21 @@ BuildPrompt = Callable[..., dict[str, Any]]
AxisRng = Callable[[dict[str, int], str, int, int], Any]
def build_insta_pair_rows(
@dataclass(frozen=True)
class InstaPairRowsRoute:
soft_row: dict[str, Any]
hard_row: dict[str, Any]
hard_content_rng: Any
def as_dict(self) -> dict[str, Any]:
return {
"soft_row": self.soft_row,
"hard_row": self.hard_row,
"hard_content_rng": self.hard_content_rng,
}
def build_insta_pair_rows_result(
*,
row_number: int,
start_index: int,
@@ -52,7 +67,7 @@ def build_insta_pair_rows(
softcore_item_prompt_label: Callable[[str], str],
pov_prompt_directive: Callable[[list[str]], str],
pov_composition_prompt: Callable[[Any, list[str]], str],
) -> dict[str, Any]:
) -> InstaPairRowsRoute:
soft_content_rng = axis_rng(parsed_seed_config, "content", seed, row_number + 311)
hard_content_rng = axis_rng(parsed_seed_config, "content", seed, row_number + 317)
soft_person_rng = axis_rng(parsed_seed_config, "person", seed, row_number)
@@ -186,8 +201,88 @@ def build_insta_pair_rows(
hard_row["pov_character_labels"] = pov_character_labels
hard_row["pov_prompt_directive"] = pov_prompt_directive(pov_character_labels)
return {
"soft_row": soft_row,
"hard_row": hard_row,
"hard_content_rng": hard_content_rng,
}
return InstaPairRowsRoute(
soft_row=soft_row,
hard_row=hard_row,
hard_content_rng=hard_content_rng,
)
def build_insta_pair_rows(
*,
row_number: int,
start_index: int,
seed: int,
active_trigger: str,
parsed_seed_config: dict[str, int],
options: dict[str, Any],
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
character_profile: str | dict[str, Any] | None,
character_cast: str | dict[str, Any] | list[Any] | None,
character_slot_map: dict[str, dict[str, Any]],
pov_character_labels: list[str],
hard_women_count: int,
hard_men_count: int,
soft_category: str,
soft_subcategory: str,
softcore_level_key: str,
hardcore_random_subcategory: str,
hardcore_position_config: str | dict[str, Any] | None,
location_config: str | dict[str, Any] | None,
composition_config: str | dict[str, Any] | None,
build_prompt: BuildPrompt,
axis_rng: AxisRng,
cast_expression_intensity_override: Callable[
[float, dict[str, dict[str, Any]], int, int, str],
tuple[float | None, str],
],
context_from_character_slot: Callable[[Any, dict[str, Any], str, str, str, bool, bool], dict[str, Any]],
apply_character_context_to_row: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]],
disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]],
slot_softcore_outfit: Callable[[dict[str, Any] | None, Any], str],
softcore_outfit: Callable[[Any, str], str],
softcore_pose: Callable[[Any, str], str],
softcore_item_prompt_label: Callable[[str], str],
pov_prompt_directive: Callable[[list[str]], str],
pov_composition_prompt: Callable[[Any, list[str]], str],
) -> dict[str, Any]:
return build_insta_pair_rows_result(
row_number=row_number,
start_index=start_index,
seed=seed,
active_trigger=active_trigger,
parsed_seed_config=parsed_seed_config,
options=options,
ethnicity=ethnicity,
figure=figure,
no_plus_women=no_plus_women,
no_black=no_black,
character_profile=character_profile,
character_cast=character_cast,
character_slot_map=character_slot_map,
pov_character_labels=pov_character_labels,
hard_women_count=hard_women_count,
hard_men_count=hard_men_count,
soft_category=soft_category,
soft_subcategory=soft_subcategory,
softcore_level_key=softcore_level_key,
hardcore_random_subcategory=hardcore_random_subcategory,
hardcore_position_config=hardcore_position_config,
location_config=location_config,
composition_config=composition_config,
build_prompt=build_prompt,
axis_rng=axis_rng,
cast_expression_intensity_override=cast_expression_intensity_override,
context_from_character_slot=context_from_character_slot,
apply_character_context_to_row=apply_character_context_to_row,
disable_row_expression=disable_row_expression,
slot_softcore_outfit=slot_softcore_outfit,
softcore_outfit=softcore_outfit,
softcore_pose=softcore_pose,
softcore_item_prompt_label=softcore_item_prompt_label,
pov_prompt_directive=pov_prompt_directive,
pov_composition_prompt=pov_composition_prompt,
).as_dict()
+24 -24
View File
@@ -2812,7 +2812,7 @@ def build_insta_of_pair(
pov_character_labels = _pov_character_labels(character_slot_map, hard_men_count)
softcore_level_key = str(options["softcore_level"])
soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key)
row_route = pair_rows.build_insta_pair_rows(
row_route = pair_rows.build_insta_pair_rows_result(
row_number=row_number,
start_index=start_index,
seed=seed,
@@ -2849,9 +2849,9 @@ def build_insta_of_pair(
pov_prompt_directive=_pov_prompt_directive,
pov_composition_prompt=_pov_composition_prompt,
)
soft_row = row_route["soft_row"]
hard_row = row_route["hard_row"]
hard_content_rng = row_route["hard_content_rng"]
soft_row = row_route.soft_row
hard_row = row_route.hard_row
hard_content_rng = row_route.hard_content_rng
cast_context = pair_cast.resolve_insta_pair_cast_context(
soft_row=soft_row,
@@ -2885,7 +2885,7 @@ def build_insta_of_pair(
platform_style = cast_context["platform_style"]
soft_level = cast_context["soft_level"]
hard_level = cast_context["hard_level"]
camera_route = pair_camera.resolve_insta_pair_camera(
camera_route = pair_camera.resolve_insta_pair_camera_result(
soft_row=soft_row,
hard_row=hard_row,
options=options,
@@ -2903,20 +2903,20 @@ def build_insta_of_pair(
composition_prompt=_composition_prompt,
camera_scene_directive_for_context=_camera_scene_directive_for_context,
)
soft_row = camera_route["soft_row"]
hard_row = camera_route["hard_row"]
hard_scene = camera_route["hard_scene"]
hard_composition = camera_route["hard_composition"]
soft_camera_config = camera_route["soft_camera_config"]
hard_camera_config = camera_route["hard_camera_config"]
soft_camera_directive = camera_route["soft_camera_directive"]
hard_camera_directive = camera_route["hard_camera_directive"]
soft_camera_scene_directive = camera_route["soft_camera_scene_directive"]
hard_camera_scene_directive = camera_route["hard_camera_scene_directive"]
soft_camera_scene_sentence = camera_route["soft_camera_scene_sentence"]
hard_camera_scene_sentence = camera_route["hard_camera_scene_sentence"]
soft_camera_sentence = camera_route["soft_camera_sentence"]
hard_camera_sentence = camera_route["hard_camera_sentence"]
soft_row = camera_route.soft_row
hard_row = camera_route.hard_row
hard_scene = camera_route.hard_scene
hard_composition = camera_route.hard_composition
soft_camera_config = camera_route.soft_camera_config
hard_camera_config = camera_route.hard_camera_config
soft_camera_directive = camera_route.soft_camera_directive
hard_camera_directive = camera_route.hard_camera_directive
soft_camera_scene_directive = camera_route.soft_camera_scene_directive
hard_camera_scene_directive = camera_route.hard_camera_scene_directive
soft_camera_scene_sentence = camera_route.soft_camera_scene_sentence
hard_camera_scene_sentence = camera_route.hard_camera_scene_sentence
soft_camera_sentence = camera_route.soft_camera_sentence
hard_camera_sentence = camera_route.hard_camera_sentence
soft_cast = cast_context["soft_cast"]
soft_cast_presence = cast_context["soft_cast_presence"]
soft_cast_styling_sentence = cast_context["soft_cast_styling_sentence"]
@@ -2929,7 +2929,7 @@ def build_insta_of_pair(
hard_content_rng,
_slot_hardcore_clothing,
)
clothing_route = pair_clothing.resolve_hardcore_pair_clothing(
clothing_route = pair_clothing.resolve_hardcore_pair_clothing_result(
hard_row=hard_row,
mode=options["hardcore_clothing_continuity"],
softcore_outfit=soft_row["item"],
@@ -2940,10 +2940,10 @@ def build_insta_of_pair(
continuity_map=INSTA_OF_HARDCORE_CLOTHING_CONTINUITY,
choose=g.choose,
)
default_man_hardcore_clothing_entries = clothing_route["default_man_hardcore_clothing"]
hard_clothing_state = clothing_route["hardcore_clothing_state"]
hard_clothing_sentence = clothing_route["hardcore_clothing_sentence"]
if clothing_route["requires_body_exposure_scene"]:
default_man_hardcore_clothing_entries = clothing_route.default_man_hardcore_clothing
hard_clothing_state = clothing_route.hardcore_clothing_state
hard_clothing_sentence = clothing_route.hardcore_clothing_sentence
if clothing_route.requires_body_exposure_scene:
hard_scene = pair_clothing.body_exposure_scene_text(hard_scene)
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
hard_row["scene_text"] = hard_scene
+145
View File
@@ -45,8 +45,10 @@ import krea_cast # noqa: E402
import krea_formatter # noqa: E402
import location_config # noqa: E402
import loop_nodes # noqa: E402
import pair_camera # noqa: E402
import pair_cast # noqa: E402
import pair_clothing # noqa: E402
import pair_rows # noqa: E402
import prompt_builder as pb # noqa: E402
import pov_policy # noqa: E402
import row_normalization # noqa: E402
@@ -2922,6 +2924,148 @@ def smoke_pair_options_policy() -> None:
_expect(pb._insta_of_softcore_category("social_tease") == ("Casual clothes", "Casual clothes / Smart casual"), "softcore category mapping changed")
def smoke_pair_route_policy() -> None:
def _fake_build_prompt(**kwargs: Any) -> dict[str, Any]:
is_hard = kwargs.get("category") == "Hardcore sexual poses"
return {
"category": kwargs.get("category"),
"subcategory": kwargs.get("subcategory"),
"scene_text": "shared test room",
"source_scene_text": "",
"composition": "centered test composition",
"source_composition": "",
"expression": "steady look",
"role_graph": "test role graph",
"item": "test item",
"positive_suffix": "test positive suffix",
"negative_prompt": "test negative",
"prompt": "test hard prompt" if is_hard else "test soft prompt",
"caption": "test hard caption" if is_hard else "test soft caption",
}
pair_options_data = {
"softcore_cast": "solo",
"softcore_expression_enabled": True,
"softcore_expression_intensity": 0.45,
"hardcore_expression_enabled": True,
"hardcore_expression_intensity": 0.85,
"hardcore_detail_density": "balanced",
}
pair_rows_kwargs: dict[str, Any] = {
"row_number": 1,
"start_index": 1,
"seed": 10,
"active_trigger": Trigger,
"parsed_seed_config": {},
"options": pair_options_data,
"ethnicity": "any",
"figure": "random",
"no_plus_women": False,
"no_black": False,
"character_profile": "",
"character_cast": "",
"character_slot_map": {},
"pov_character_labels": [],
"hard_women_count": 1,
"hard_men_count": 1,
"soft_category": "Casual clothes",
"soft_subcategory": "Casual clothes / Smart casual",
"softcore_level_key": "social_tease",
"hardcore_random_subcategory": pb.RANDOM_SUBCATEGORY,
"hardcore_position_config": "",
"location_config": "",
"composition_config": "",
"build_prompt": _fake_build_prompt,
"axis_rng": lambda _config, _axis, seed_value, row_value: random.Random(seed_value + row_value),
"cast_expression_intensity_override": lambda value, _slots, _women, _men, _phase: (value, "input"),
"context_from_character_slot": lambda *_args, **_kwargs: {},
"apply_character_context_to_row": lambda row, context: {**row, **context},
"disable_row_expression": lambda row, source: {**row, "expression_disabled": True, "expression_intensity_source": source},
"slot_softcore_outfit": lambda _slot, _rng: "",
"softcore_outfit": lambda _rng, _level: "test soft outfit",
"softcore_pose": lambda _rng, _level: "test soft pose",
"softcore_item_prompt_label": lambda _level: "Softcore test outfit",
"pov_prompt_directive": lambda labels: "POV directive" if labels else "",
"pov_composition_prompt": lambda composition, labels: f"{composition} for {','.join(labels)}" if labels else str(composition),
}
rows_route = pair_rows.build_insta_pair_rows_result(**pair_rows_kwargs)
rows_legacy = pair_rows.build_insta_pair_rows(**pair_rows_kwargs)
_expect(rows_route.soft_row == rows_legacy["soft_row"], "Typed pair row route should match legacy soft row")
_expect(rows_route.hard_row == rows_legacy["hard_row"], "Typed pair row route should match legacy hard row")
_expect(
rows_route.hard_content_rng.getstate() == rows_legacy["hard_content_rng"].getstate(),
"Typed pair row route should match legacy hard content RNG state",
)
_expect(rows_route.soft_row["item"] == "test soft outfit", "Typed pair row route lost soft outfit override")
_expect(rows_route.hard_row["hardcore_detail_density"] == "balanced", "Typed pair row route lost hard density")
camera_options = {
"hardcore_camera_mode": "same_as_softcore",
"softcore_camera_mode": "standard",
"camera_detail": "compact",
"softcore_cast": "same_as_hardcore",
"continuity": "same_creator_same_room",
}
def _camera_config_with_mode(source: Any, mode: str) -> dict[str, Any]:
parsed = dict(source or {})
parsed["camera_mode"] = mode
return parsed
def _camera_directive(config: dict[str, Any]) -> tuple[str, dict[str, Any]]:
return f"{config.get('camera_mode')} camera", config
def _camera_rows() -> tuple[dict[str, Any], dict[str, Any]]:
return (
{"scene_text": "soft room", "composition": "soft composition"},
{"scene_text": "hard room", "composition": "hard composition"},
)
camera_common = {
"options": camera_options,
"camera_config": {"base": "camera"},
"softcore_camera_config": None,
"hardcore_camera_config": None,
"hard_women_count": 1,
"hard_men_count": 1,
"pov_character_labels": [],
"camera_detail_choices": ("compact",),
"camera_config_with_mode": _camera_config_with_mode,
"camera_directive": _camera_directive,
"apply_contextual_composition": lambda row, subject_kind: {**row, "subject_kind": subject_kind},
"contextual_composition_prompt": lambda scene, composition, subject_kind: f"{subject_kind}: {scene}: {composition}",
"composition_prompt": lambda composition: f"Framed as {composition}",
"camera_scene_directive_for_context": lambda _scene, _composition, config, _pov, subject: (f"{subject} scene directive", config),
}
soft_row, hard_row = _camera_rows()
camera_route = pair_camera.resolve_insta_pair_camera_result(soft_row=soft_row, hard_row=hard_row, **camera_common)
soft_row, hard_row = _camera_rows()
camera_legacy = pair_camera.resolve_insta_pair_camera(soft_row=soft_row, hard_row=hard_row, **camera_common)
_expect(camera_route.as_dict() == camera_legacy, "Typed pair camera route should match legacy dict route")
_expect(camera_route.hard_scene == "soft room", "Typed pair camera route lost same-room continuity")
_expect(camera_route.hard_camera_sentence.startswith("Camera control:"), "Typed pair camera route lost hard camera sentence")
clothing_common = {
"hard_row": {
"role_graph": "the man thrusts his penis into the woman",
"item": "penetrative test item",
},
"mode": "explicit_nude",
"softcore_outfit": "test lingerie",
"character_hardcore_clothing_entries": [],
"men_count": 0,
"pov_labels": [],
"rng": random.Random(1),
"continuity_map": pb.INSTA_OF_HARDCORE_CLOTHING_CONTINUITY,
"choose": lambda _rng, pool: pool[0],
}
clothing_route = pair_clothing.resolve_hardcore_pair_clothing_result(**clothing_common)
clothing_legacy = pair_clothing.resolve_hardcore_pair_clothing(**clothing_common)
_expect(clothing_route.as_dict() == clothing_legacy, "Typed pair clothing route should match legacy dict route")
_expect(clothing_route.woman_access == "lower", "Typed pair clothing route lost lower-access detection")
_expect(clothing_route.requires_body_exposure_scene is True, "Typed pair clothing route lost exposure-scene flag")
def _expect_pair(pair: dict[str, Any], name: str) -> None:
_expect(pair.get("mode") == "Insta/OF", f"{name}.mode should be Insta/OF")
_expect_row_base(pair.get("softcore_row") or {}, f"{name}.softcore_row")
@@ -4823,6 +4967,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [
("hardcore_category_routes", smoke_hardcore_category_routes),
("krea_close_foreplay_route", smoke_krea_close_foreplay_route),
("pair_options_policy", smoke_pair_options_policy),
("pair_route_policy", smoke_pair_route_policy),
("insta_pair_same_cast", smoke_insta_pair),
("krea_pair_clothing_state", smoke_krea_pair_clothing_state),
("insta_pair_pov_man", smoke_insta_pair_pov),