Add POV participant mode

This commit is contained in:
2026-06-24 18:02:30 +02:00
parent fb6d99ac20
commit 89e499537e
4 changed files with 377 additions and 27 deletions
+172 -7
View File
@@ -175,6 +175,7 @@ CHARACTER_MAN_BODY_CHOICES = [
"fat",
]
CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"]
CHARACTER_PRESENCE_CHOICES = ["visible", "pov"]
CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
@@ -1429,6 +1430,10 @@ def character_descriptor_detail_choices() -> list[str]:
return list(CHARACTER_DESCRIPTOR_DETAIL_CHOICES)
def character_presence_choices() -> list[str]:
return list(CHARACTER_PRESENCE_CHOICES)
def character_ethnicity_choices() -> list[str]:
return ["random"] + list(ETHNICITY_FILTER_CHOICES)
@@ -1863,6 +1868,21 @@ def _normalize_descriptor_detail(value: Any) -> str:
return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto"
def _normalize_presence_mode(value: Any, subject_type: str) -> str:
text = str(value or "visible").strip().lower()
if text not in CHARACTER_PRESENCE_CHOICES:
text = "visible"
if subject_type != "man":
return "visible"
return text
def _slot_is_pov(slot: dict[str, Any] | None) -> bool:
if not slot:
return False
return slot.get("subject_type") == "man" and slot.get("presence_mode") == "pov"
def _normalize_slot_expression_intensity(value: Any) -> float:
try:
intensity = float(value)
@@ -1907,6 +1927,8 @@ def _cast_expression_intensity_override(
value_labels: list[str] = []
for label in labels:
slot = label_map.get(label)
if _slot_is_pov(slot):
continue
if slot:
matching_slots.append(slot)
value = _slot_expression_intensity(slot)
@@ -1943,6 +1965,8 @@ def _character_expression_entries(
slot = label_map.get(label)
if not slot:
continue
if _slot_is_pov(slot):
continue
if not _slot_expression_enabled(slot):
continue
intensity = _slot_expression_intensity(slot)
@@ -2050,6 +2074,7 @@ def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
"hair": _slot_value(slot.get("hair")),
"eyes": _slot_value(slot.get("eyes")),
"descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")),
"presence_mode": _normalize_presence_mode(slot.get("presence_mode"), subject_type),
"expression_enabled": not _is_false(slot.get("expression_enabled", True)),
"expression_intensity": _normalize_slot_expression_intensity(slot.get("expression_intensity")),
}
@@ -2096,6 +2121,8 @@ def _character_slot_summary(slot: dict[str, Any]) -> str:
f"body={slot.get('body', 'random')}",
f"detail={slot.get('descriptor_detail', 'auto')}",
]
if _slot_is_pov(slot):
parts.append("presence=pov")
if not _slot_expression_enabled(slot):
parts.append("expression=disabled")
else:
@@ -2127,6 +2154,7 @@ def build_character_slot_json(
expression_intensity: float = -1.0,
enabled: bool = True,
character_cast: str | dict[str, Any] | list[Any] | None = "",
presence_mode: str = "visible",
) -> dict[str, str]:
existing_slots = _parse_character_cast(character_cast)
slot = _normalize_character_slot(
@@ -2144,6 +2172,7 @@ def build_character_slot_json(
"hair": hair,
"eyes": eyes,
"descriptor_detail": descriptor_detail,
"presence_mode": presence_mode,
"expression_enabled": expression_enabled,
"expression_intensity": expression_intensity,
}
@@ -2186,6 +2215,62 @@ def _character_slot_label_map(slots: list[dict[str, Any]]) -> dict[str, dict[str
return label_map
def _pov_character_labels(
label_map: dict[str, dict[str, Any]],
men_count: int | None = None,
) -> list[str]:
if men_count is None:
labels = sorted(label for label in label_map if label.startswith("Man "))
else:
labels = [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]
return [label for label in labels if _slot_is_pov(label_map.get(label))]
def _pov_text_with_viewer(text: Any, pov_labels: list[str]) -> str:
rendered = str(text or "").strip()
if not rendered or not pov_labels:
return rendered
for label in sorted(pov_labels, key=len, reverse=True):
escaped = re.escape(label)
rendered = re.sub(rf"\b{escaped}'s\b", "the POV viewer's", rendered)
rendered = re.sub(rf"\b{escaped}\b", "the POV viewer", rendered)
rendered = re.sub(r"\bthe POV viewer is positioned\b", "the POV camera is positioned", rendered, flags=re.IGNORECASE)
return _clean_prompt_punctuation(rendered)
def _pov_role_graph_prompt(role_graph: Any, pov_labels: list[str]) -> str:
role_graph_text = str(role_graph or "").strip()
if not role_graph_text or not pov_labels:
return role_graph_text
viewer_text = _pov_text_with_viewer(role_graph_text, pov_labels)
label_text = ", ".join(pov_labels)
return f"First-person POV from {label_text}; {viewer_text}"
def _pov_prompt_directive(pov_labels: list[str]) -> str:
if not pov_labels:
return ""
label_text = ", ".join(pov_labels)
return (
f"POV participant: {label_text} is the first-person camera viewpoint; "
"he remains the off-camera viewpoint, represented by foreground hands, body position, or camera perspective cues when needed."
)
def _pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str:
text = str(composition or "").strip()
if not text or not pov_labels:
return text
text = re.sub(r"\ball participants visible\b", "visible partners readable", text, flags=re.IGNORECASE)
text = re.sub(r"\ball adult bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE)
text = re.sub(r"\ball bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE)
text = re.sub(r"\ball three bodies readable\b", "visible partner bodies readable", text, flags=re.IGNORECASE)
text = re.sub(r"\bwide group-sex composition\b", "first-person group-sex POV composition", text, flags=re.IGNORECASE)
if "pov" not in text.lower() and "first-person" not in text.lower():
text = f"{text}, adapted for first-person POV with the POV participant kept off-camera"
return _clean_prompt_punctuation(text)
def _context_from_character_slot(
rng: random.Random,
slot: dict[str, Any],
@@ -2228,6 +2313,7 @@ def _context_from_character_slot(
if value:
context[key] = value
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:
@@ -2267,6 +2353,7 @@ def _apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]
"eyes",
"figure",
"descriptor_detail",
"presence_mode",
"expression_enabled",
"expression_intensity",
):
@@ -2304,6 +2391,8 @@ def _cast_descriptor_entries(
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
@@ -2786,6 +2875,34 @@ def _role_graph(
return f"{woman} kneels in front of {man} at hip height while {man} ejaculates semen onto her face, lips, and chest."
return f"{woman} lies on her back with thighs open while {man} kneels between her legs and ejaculates semen onto her body."
def penetration_position_graph(woman: str, man: str) -> str:
text = " ".join(
str(part or "").lower()
for part in (
item_text,
*((item_axis_values or {}).values()),
)
)
if "missionary" in text:
return f"{woman} lies on her back with legs open while {man} is above her and {man}'s penis thrusts into her."
if "reverse cowgirl" in text:
return f"{woman} straddles {man}'s hips facing away while {man} lies under her and {man}'s penis thrusts into her."
if "cowgirl" in text or "straddling" in text:
return f"{woman} straddles {man}'s hips facing him while {man} lies under her and {man}'s penis thrusts into her."
if "doggy" in text or "rear-entry" in text or "bent-over" in text or "bent over" in text:
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and {man}'s penis thrusts into her."
if "standing" in text:
return f"{woman} stands braced with hips angled back while {man} stands behind her and {man}'s penis thrusts into her."
if "spooning" in text or "side-lying" in text:
return f"{woman} lies on her side with thighs parted while {man} presses behind her and {man}'s penis thrusts into her."
if "edge-of-bed" in text or "edge of bed" in text or "bed edge" in text:
return f"{woman} lies at the bed edge with hips near the edge while {man} kneels between her legs and {man}'s penis thrusts into her."
if "kneeling straddle" in text:
return f"{woman} kneels straddling {man}'s hips while {man} supports her waist and {man}'s penis thrusts into her."
if "lotus" in text:
return f"{woman} sits in {man}'s lap facing him with legs around his hips while {man}'s penis thrusts into her."
return f"{woman} lies on her back with thighs open while {man} kneels between her legs and {man}'s penis thrusts into her."
if people_count == 1:
solo = people[0]
if women_count == 1:
@@ -2870,7 +2987,7 @@ def _role_graph(
elif "cumshot" in slug or "climax" in slug:
graph = climax_position_graph(woman, man, third)
else:
graph = f"{man}'s penis thrusts into {woman} with penetration and body contact visible."
graph = penetration_position_graph(woman, man)
return graph + support_sentence({woman, man, third} if third else {woman, man})
@@ -3246,7 +3363,13 @@ def _build_custom_row(
else:
context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile)
subject_type = context["subject_type"]
role_graph = _role_graph(role_rng, subcategory, context, item_axis_values)
pov_character_labels = (
_pov_character_labels(character_slot_map, men_count)
if subject_type == "configured_cast"
else []
)
source_role_graph = _role_graph(role_rng, subcategory, context, item_axis_values)
role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels)
cast_descriptors: list[str] = []
cast_descriptor_text = ""
expression_intensity_source = "input"
@@ -3320,10 +3443,11 @@ def _build_custom_row(
character_expression_text = "; ".join(character_expressions)
if character_expression_text:
expression = character_expression_text
composition = _choose_text(
source_composition = _choose_text(
composition_rng,
_compatible_entries(_composition_pool(category, subcategory, item, subject_type), women_count, men_count),
)
composition = _pov_composition_prompt(source_composition, pov_character_labels)
negative_prompt = str(_merged_field(category, subcategory, item, "negative_prompt", g.NEGATIVE_PROMPT))
positive_suffix = str(_merged_field(category, subcategory, item, "positive_suffix", GENERIC_POSITIVE_SUFFIX))
@@ -3360,8 +3484,12 @@ def _build_custom_row(
"expression_intensity": expression_intensity,
"expression_intensity_source": expression_intensity_source,
"composition": composition,
"source_composition": source_composition,
"composition_prompt": _composition_prompt(composition),
"role_graph": role_graph,
"source_role_graph": source_role_graph,
"pov_character_labels": pov_character_labels,
"pov_prompt_directive": _pov_prompt_directive(pov_character_labels),
"cast_descriptors": cast_descriptor_text,
"positive_suffix": positive_suffix,
"negative_prompt": negative_prompt,
@@ -3392,6 +3520,8 @@ def _build_custom_row(
prompt = _format(template, context)
if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in template:
prompt = _insert_positive_directive(prompt, f"Characters: {cast_descriptor_text}.")
if subject_type == "configured_cast" and pov_character_labels:
prompt = _insert_positive_directive(prompt, _pov_prompt_directive(pov_character_labels))
caption = _format(caption_template, context)
if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in caption_template:
caption = f"{caption.rstrip()}, {cast_descriptor_text}"
@@ -3425,6 +3555,10 @@ def _build_custom_row(
"seed_config": seed_config,
"content_seed_axis": content_axis,
"role_graph": role_graph,
"source_role_graph": source_role_graph,
"source_composition": source_composition,
"pov_character_labels": pov_character_labels,
"pov_prompt_directive": _pov_prompt_directive(pov_character_labels),
"shared_expression": shared_expression,
"character_expressions": character_expressions,
"character_expression_text": character_expression_text,
@@ -4020,15 +4154,19 @@ def _insta_of_partner_styling(
row_number: int,
women_count: int,
men_count: int,
pov_labels: list[str] | None = None,
) -> dict[str, Any]:
content_rng = _axis_rng(seed_config, "content", seed, row_number + 421)
pose_rng = _axis_rng(seed_config, "pose", seed, row_number + 421)
pov_set = set(pov_labels or [])
outfits: list[str] = []
for index in range(max(0, women_count - 1)):
label = chr(ord("B") + index)
outfits.append(f"Woman {label} wears {g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS)}")
for index in range(max(0, men_count)):
label = chr(ord("A") + index)
if f"Man {label}" in pov_set:
continue
outfits.append(f"Man {label} wears {g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS)}")
return {
"outfits": outfits,
@@ -4071,6 +4209,7 @@ def build_insta_of_pair(
parsed_seed_config = _parse_seed_config(seed_config)
character_slots = _parse_character_cast(character_cast)
character_slot_map = _character_slot_label_map(character_slots)
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)
soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311)
@@ -4143,6 +4282,18 @@ def build_insta_of_pair(
soft_row["item_label"] = "Insta/OF softcore outfit"
soft_row["custom_item"] = "insta_of_softcore_outfit"
soft_row["softcore_outfit_policy"] = "insta_of_safe_softcore"
soft_row["pov_character_labels"] = (
pov_character_labels
if options["softcore_cast"] == "same_as_hardcore"
else []
)
soft_row["pov_prompt_directive"] = _pov_prompt_directive(soft_row["pov_character_labels"])
if soft_row["pov_character_labels"]:
soft_row["source_composition"] = soft_row.get("source_composition") or soft_row.get("composition", "")
soft_row["composition"] = _pov_composition_prompt(
soft_row["source_composition"],
soft_row["pov_character_labels"],
)
hard_row = build_prompt(
category="Hardcore sexual poses",
subcategory=RANDOM_SUBCATEGORY,
@@ -4170,6 +4321,8 @@ def build_insta_of_pair(
character_cast=character_cast or "",
)
hard_row["hardcore_detail_density"] = options["hardcore_detail_density"]
hard_row["pov_character_labels"] = pov_character_labels
hard_row["pov_prompt_directive"] = _pov_prompt_directive(pov_character_labels)
descriptor = _insta_of_descriptor(soft_row)
cast_descriptors = _insta_of_cast_descriptors(
@@ -4197,6 +4350,7 @@ def build_insta_of_pair(
row_number,
hard_women_count if options["softcore_cast"] == "same_as_hardcore" else 1,
hard_men_count if options["softcore_cast"] == "same_as_hardcore" else 0,
pov_character_labels if options["softcore_cast"] == "same_as_hardcore" else [],
)
if options["softcore_cast"] != "same_as_hardcore":
soft_partner_styling = {"outfits": [], "pose": ""}
@@ -4223,9 +4377,16 @@ def build_insta_of_pair(
else f"non-explicit teaser setup with {_insta_of_cast_phrase(hard_women_count, hard_men_count)}"
)
soft_cast_presence = (
"Place Woman A and the listed partners together in a non-explicit teaser pose with no sex act or genital contact. "
if options["softcore_cast"] == "same_as_hardcore"
else "Keep the softcore version focused on Woman A alone. "
(
"Frame Woman A from the POV participant's first-person camera in a non-explicit teaser setup; "
"keep the POV participant off-camera as the viewpoint and implied by camera perspective or foreground cues. "
)
if options["softcore_cast"] == "same_as_hardcore" and pov_character_labels
else (
"Place Woman A and the listed partners together in a non-explicit teaser pose with no sex act or genital contact. "
if options["softcore_cast"] == "same_as_hardcore"
else "Keep the softcore version focused on Woman A alone. "
)
)
soft_cast_styling_sentence = (
f"Partner softcore styling: {soft_partner_outfit_text}. Cast pose: {soft_partner_styling['pose']}. "
@@ -4243,6 +4404,7 @@ def build_insta_of_pair(
"balanced": "",
"dense": "Use dense but coherent motion, contact, and aftermath detail while keeping one readable body position. ",
}[hard_detail_density]
pov_directive = _pov_prompt_directive(pov_character_labels)
soft_descriptor_sentence = (
f"Cast descriptors: {soft_cast_descriptor_text}. "
if options["softcore_cast"] == "same_as_hardcore"
@@ -4266,7 +4428,8 @@ def build_insta_of_pair(
f"Insta/OF hardcore mode: {platform_style}. "
f"Hardcore setup: {hard_level}. Cast: {hard_cast}. "
f"Cast descriptors: {cast_descriptor_text}. "
"Keep Woman A visually central. "
f"{pov_directive + ' ' if pov_directive else ''}"
f"{'Keep Woman A visually central from the POV camera. ' if pov_character_labels else 'Keep Woman A visually central. '}"
f"{hard_clothing_state} "
f"Role graph: {hard_row['role_graph']} Sexual scene: {hard_row['item']}. "
f"Setting: {hard_scene}. "
@@ -4316,6 +4479,8 @@ def build_insta_of_pair(
"options": options,
"shared_descriptor": descriptor,
"shared_cast_descriptors": cast_descriptors,
"pov_character_labels": pov_character_labels,
"pov_prompt_directive": pov_directive,
"softcore_partner_styling": soft_partner_styling,
"hardcore_clothing_state": hard_clothing_state,
"hardcore_detail_density": hard_detail_density,