From 89e499537ed0587837c6ba92434d4b897924c750 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 18:02:30 +0200 Subject: [PATCH] Add POV participant mode --- README.md | 15 ++++ __init__.py | 8 ++ krea_formatter.py | 202 +++++++++++++++++++++++++++++++++++++++++----- prompt_builder.py | 179 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 377 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fe43c22..b325471 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,13 @@ is emitted into named-cast descriptors: couple/group prompts without turning every partner into a fully detailed primary character. Set a man slot to `full` when the partner needs exact hair/eye detail. +`SxCP Man Slot` also has `presence_mode`. Use `visible` for a normal rendered +partner. Use `pov` when that man is the first-person camera participant: he +remains part of the cast and role graph, but his appearance descriptor and +per-character expression text are omitted, and pose wording is rendered from +the POV participant's viewpoint. The generic `SxCP Character Slot` exposes the +same field, but it only has an effect when `subject_type=man`. + Slots also expose `expression_enabled` and `expression_intensity`. Disable `expression_enabled` when that character should not receive a face/expression directive. Leave `expression_intensity` at `-1` to use the generator or Insta/OF @@ -231,6 +238,9 @@ Important behavior: - Insta/OF cast metadata is rewritten as direct named-character prose such as `Woman A is ...` and `Man A is ...`, so Krea2 does not have to interpret a `Cast descriptors:` label. +- Man slots set to `presence_mode=pov` are not emitted as visible cast prose. + The formatter keeps them in the role graph, rewrites the action from the + first-person viewer position, and adds a POV camera sentence. It outputs: @@ -261,6 +271,11 @@ the shared primary creator (`Woman A`) in both softcore and hardcore outputs; additional woman/man slots fill partner descriptors before random fallback descriptors are used. +For POV pair prompts, set the relevant `SxCP Man Slot` to +`presence_mode=pov`. The softcore output frames the primary creator from that +participant's camera, while the hardcore output keeps the same cast and scene +but converts the role graph into first-person positioning. + It outputs: - `softcore_prompt` diff --git a/__init__.py b/__init__.py index 684d392..e48192e 100644 --- a/__init__.py +++ b/__init__.py @@ -37,6 +37,7 @@ try: character_figure_choices, character_label_choices, character_man_body_choices, + character_presence_choices, character_profile_choices, character_woman_body_choices, ethnicity_choices, @@ -82,6 +83,7 @@ except ImportError: character_figure_choices, character_label_choices, character_man_body_choices, + character_presence_choices, character_profile_choices, character_woman_body_choices, ethnicity_choices, @@ -611,6 +613,7 @@ class SxCPCharacterSlot: "descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}), "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "presence_mode": (character_presence_choices(), {"default": "visible"}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -640,6 +643,7 @@ class SxCPCharacterSlot: descriptor_detail="auto", expression_enabled=True, expression_intensity=-1.0, + presence_mode="visible", character_cast="", ): result = build_character_slot_json( @@ -658,6 +662,7 @@ class SxCPCharacterSlot: descriptor_detail=descriptor_detail, expression_enabled=expression_enabled, expression_intensity=expression_intensity, + presence_mode=presence_mode, enabled=enabled, character_cast=character_cast or "", ) @@ -755,6 +760,7 @@ class SxCPManSlot: "descriptor_detail": (character_descriptor_detail_choices(), {"default": "compact"}), "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "presence_mode": (character_presence_choices(), {"default": "visible"}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -782,6 +788,7 @@ class SxCPManSlot: descriptor_detail="compact", expression_enabled=True, expression_intensity=-1.0, + presence_mode="visible", character_cast="", ): result = build_character_slot_json( @@ -800,6 +807,7 @@ class SxCPManSlot: descriptor_detail=descriptor_detail, expression_enabled=expression_enabled, expression_intensity=expression_intensity, + presence_mode=presence_mode, enabled=enabled, character_cast=character_cast or "", ) diff --git a/krea_formatter.py b/krea_formatter.py index 3a4a143..0efbe66 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -230,8 +230,16 @@ def _natural_label_text(text: Any, labels: list[str]) -> str: return text -def _cast_prose(text: str, central_label: str = "Woman A") -> tuple[str, list[str]]: - entries = _cast_entries(text) +def _cast_prose( + text: str, + central_label: str = "Woman A", + omit_labels: list[str] | set[str] | tuple[str, ...] = (), +) -> tuple[str, list[str]]: + raw_entries = _cast_entries(text) + omitted = set(omit_labels or []) + entries = [(label, descriptor) for label, descriptor in raw_entries if label not in omitted] + if raw_entries and not entries: + return "", [] if not entries: return (f"{central_label} is {_clean(text)}" if _clean(text) else "", []) labels = [label for label, _descriptor in entries] @@ -250,6 +258,87 @@ def _cast_prose(text: str, central_label: str = "Woman A") -> tuple[str, list[st return " ".join(sentences), labels +def _pov_labels_from_value(value: Any) -> list[str]: + labels: list[str] = [] + if isinstance(value, list): + candidates = value + else: + candidates = re.split(r"[,;]\s*", _clean(value)) if _clean(value) else [] + for candidate in candidates: + label = _clean(candidate) + if re.match(r"^Man [A-Z]$", label) and label not in labels: + labels.append(label) + return labels + + +def _merge_labels(*groups: list[str]) -> list[str]: + merged: list[str] = [] + for group in groups: + for label in group: + if label and label not in merged: + merged.append(label) + return merged + + +def _filter_pov_labeled_clauses(text: Any, pov_labels: list[str]) -> str: + rendered = _clean(text) + if not rendered or not pov_labels: + return rendered + clauses = [clause.strip() for clause in rendered.split(";") if clause.strip()] + filtered = [ + clause + for clause in clauses + if not any(re.match(rf"^{re.escape(label)}\b", clause) for label in pov_labels) + ] + return "; ".join(filtered) + + +def _pov_action_phrase(action: Any, pov_labels: list[str]) -> str: + rendered = _clean(action) + 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) + if "Man A" in pov_labels: + rendered = re.sub(r"\bthe man's\b", "the POV viewer's", rendered, flags=re.IGNORECASE) + rendered = re.sub(r"\bthe man\b", "the POV viewer", rendered, flags=re.IGNORECASE) + rendered = re.sub(r"\bhe\b", "the POV viewer", rendered, flags=re.IGNORECASE) + rendered = re.sub(r"\bhim\b", "the POV viewer", rendered, flags=re.IGNORECASE) + rendered = re.sub(r"\bhis\b", "the POV viewer's", rendered, flags=re.IGNORECASE) + rendered = re.sub(r"\bthe POV viewer is positioned\b", "the POV camera is positioned", rendered, flags=re.IGNORECASE) + return rendered + + +def _pov_camera_phrase(pov_labels: list[str], softcore: bool = False) -> str: + if not pov_labels: + return "" + if softcore: + return ( + "first-person POV creator framing from the male participant; " + "the woman is the visible subject, and the participant is implied by camera position or foreground cues" + ) + return ( + "first-person POV from the male participant; keep the visible partners readable from the viewer's position, " + "using only foreground hands, body position, or camera perspective cues for the POV participant" + ) + + +def _pov_composition_text(composition: Any, pov_labels: list[str]) -> str: + text = _clean(composition) + 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(text) + + def _sanitize_scene_text_for_cast(text: Any, labels: list[str]) -> str: text = _clean(text) if not text: @@ -633,6 +722,34 @@ def _hardcore_pose_arrangement(anchor: str, role_graph: str, hard_item: str, com return "" +def _arrangement_duplicates_role(arrangement: str, role_graph: str) -> bool: + arrangement_lower = _clean(arrangement).lower() + role_lower = _clean(role_graph).lower() + if not arrangement_lower or not role_lower: + return False + markers = ( + "bed edge", + "on all fours", + "face-down", + "hips raised", + "bent forward", + "straddl", + "on her back", + "on their sides", + "on her side", + "seated in", + "sits in", + "lap", + "kneeling between", + "kneels between", + "kneeling in front", + "kneels in front", + "positioned behind", + "standing", + ) + return any(marker in arrangement_lower and marker in role_lower for marker in markers) + + def _hardcore_item_detail(hard_item: str) -> str: text = _clean(hard_item).rstrip(".") if not text: @@ -1071,7 +1188,7 @@ def _hardcore_action_sentence( detail = _limit_detail_for_density(detail, detail_density, False) arrangement = _hardcore_pose_arrangement(anchor, role_graph, hard_item, composition, axis_values) anchor_phrase = _with_indefinite_article(anchor) if anchor else "" - if arrangement and anchor_phrase: + if arrangement and anchor_phrase and not _arrangement_duplicates_role(arrangement, role_graph): anchor_phrase = f"{anchor_phrase} {arrangement}" if role_graph and anchor_phrase: sentence = f"In {anchor_phrase}, {role_graph}" @@ -1258,6 +1375,12 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) if not _expression_disabled(row): expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression")) composition = re.sub(r"^vertical\s+", "", _row_value(row, "composition", ("Composition",)), flags=re.IGNORECASE) + source_composition = re.sub( + r"^vertical\s+", + "", + _clean(row.get("source_composition")) or composition, + flags=re.IGNORECASE, + ) camera = _camera_phrase(row) style = _style_phrase(row, style_mode) @@ -1274,25 +1397,31 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) or _prompt_field(_clean(row.get("prompt")), "Characters") or _prompt_field(_clean(row.get("prompt")), "Cast descriptors") ) - cast_prose, cast_labels = _cast_prose(cast_descriptor_text) + pov_labels = _pov_labels_from_value(row.get("pov_character_labels")) + cast_prose, cast_labels = _cast_prose(cast_descriptor_text, omit_labels=pov_labels) if not cast_labels and women_count == 1 and men_count == 1: cast_labels = ["Woman A", "Man A"] + cast_labels = _merge_labels(cast_labels, pov_labels) + expression = _filter_pov_labeled_clauses(expression, pov_labels) expression = _natural_label_text(expression, cast_labels) - role_graph = _sanitize_scene_text_for_cast(row.get("role_graph"), cast_labels) + role_graph = _sanitize_scene_text_for_cast(row.get("source_role_graph") or row.get("role_graph"), cast_labels) item = _sanitize_scene_text_for_cast(item, cast_labels) role_graph = _natural_label_text(role_graph, cast_labels) item = _natural_label_text(item, cast_labels) axis_values = row.get("item_axis_values") if isinstance(row.get("item_axis_values"), dict) else {} detail_density = _normalize_hardcore_detail_density(row.get("hardcore_detail_density")) - action = _hardcore_action_sentence(role_graph, item, composition, axis_values, detail_density) + action = _hardcore_action_sentence(role_graph, item, source_composition, axis_values, detail_density) + action = _pov_action_phrase(action, pov_labels) + output_composition = _pov_composition_text(composition, pov_labels) parts = [ action, + _pov_camera_phrase(pov_labels), cast_prose, f"A consensual explicit adult scene with {subject}" if not action else "", f"The cast includes {cast}" if cast and not cast_prose and not (women_count == 1 and men_count == 1) else "", f"The setting is {scene}" if scene else "", _expression_phrase(expression), - _composition_phrase(composition, action, "The image is framed as", detail_density), + _composition_phrase(output_composition, action, "The image is framed as", detail_density), camera, style if detail_level != "concise" else "", ] @@ -1367,15 +1496,26 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) same_room = options.get("continuity") == "same_creator_same_room" hard_scene = soft.get("scene_text") if same_room and soft.get("scene_text") else hard.get("scene_text") hard_composition = hard.get("composition") + hard_source_composition = hard.get("source_composition") or hard_composition + pov_labels = _merge_labels( + _pov_labels_from_value(row.get("pov_character_labels")), + _pov_labels_from_value(soft.get("pov_character_labels")), + _pov_labels_from_value(hard.get("pov_character_labels")), + ) soft_cast_descriptor_text = ( cast_descriptor_text if options.get("softcore_cast") == "same_as_hardcore" else f"Woman A: {descriptor}" ) - soft_cast_prose, soft_labels = _cast_prose(soft_cast_descriptor_text) - hard_cast_prose, hard_labels = _cast_prose(cast_descriptor_text) + soft_cast_prose, soft_labels = _cast_prose( + soft_cast_descriptor_text, + omit_labels=pov_labels if options.get("softcore_cast") == "same_as_hardcore" else [], + ) + hard_cast_prose, hard_labels = _cast_prose(cast_descriptor_text, omit_labels=pov_labels) + soft_labels = _merge_labels(soft_labels, pov_labels if options.get("softcore_cast") == "same_as_hardcore" else []) + hard_labels = _merge_labels(hard_labels, pov_labels) hard_item = _sanitize_scene_text_for_cast(hard.get("item"), hard_labels) - hard_role_graph = _sanitize_scene_text_for_cast(hard.get("role_graph"), hard_labels) + hard_role_graph = _sanitize_scene_text_for_cast(hard.get("source_role_graph") or hard.get("role_graph"), hard_labels) hard_item = _natural_label_text(hard_item, hard_labels) hard_role_graph = _natural_label_text(hard_role_graph, hard_labels) hard_axis_values = hard.get("item_axis_values") if isinstance(hard.get("item_axis_values"), dict) else {} @@ -1385,16 +1525,25 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) hard_action = _hardcore_action_sentence( hard_role_graph, hard_item, - hard_composition, + hard_source_composition, hard_axis_values, hard_detail_density, ) + hard_action = _pov_action_phrase(hard_action, pov_labels) + hard_output_composition = _pov_composition_text(hard_composition, pov_labels) same_soft_cast = options.get("softcore_cast") == "same_as_hardcore" - soft_cast_presence = ( - f"{_label_join(soft_labels)} are together in a non-explicit teaser pose, with no sex act or genital contact" - if same_soft_cast - else "The image focuses on the woman alone" - ) + soft_output_composition = _pov_composition_text(soft.get("composition"), pov_labels if same_soft_cast else []) + if same_soft_cast and pov_labels: + soft_cast_presence = ( + "the woman is framed from the POV participant's first-person camera in a non-explicit teaser pose, " + "with the POV participant kept off-camera as the viewpoint and implied by camera position or foreground cues" + ) + else: + soft_cast_presence = ( + f"{_label_join(soft_labels)} are together in a non-explicit teaser pose, with no sex act or genital contact" + if same_soft_cast + else "The image focuses on the woman alone" + ) partner_styling = row.get("softcore_partner_styling") if isinstance(partner_styling, dict): outfits = partner_styling.get("outfits") @@ -1403,18 +1552,29 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) else: partner_outfit_text = "" partner_pose = "" + partner_outfit_text = _filter_pov_labeled_clauses(partner_outfit_text, pov_labels) + if pov_labels: + partner_pose = "" partner_outfit_text = _natural_label_text(partner_outfit_text, soft_labels) soft_expression = "" if not _expression_disabled(soft): - soft_expression = _natural_label_text( + soft_expression_source = _filter_pov_labeled_clauses( _clean(soft.get("character_expression_text")) or _clean(soft.get("expression")), + pov_labels, + ) + soft_expression = _natural_label_text( + soft_expression_source, soft_labels, ) hard_expression = "" if not _expression_disabled(hard): - hard_expression = _natural_label_text( + hard_expression_source = _filter_pov_labeled_clauses( _clean(hard.get("character_expression_text")) or _clean(hard.get("expression")), + pov_labels, + ) + hard_expression = _natural_label_text( + hard_expression_source, hard_labels, ) @@ -1423,21 +1583,23 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) soft_cast_presence, partner_outfit_text, partner_pose, + _pov_camera_phrase(pov_labels, softcore=True) if same_soft_cast else "", f"wearing {soft.get('item')}" if soft.get("item") else "", f"{soft.get('pose')}" if soft.get("pose") else "", _expression_phrase(soft_expression), f"in {soft.get('scene_text')}" if soft.get("scene_text") else "", - f"framed as {soft.get('composition')}" if soft.get("composition") else "", + f"framed as {soft_output_composition}" if soft_output_composition else "", soft_camera, soft_style if detail_level != "concise" else "", ] hard_parts = [ hard_action, + _pov_camera_phrase(pov_labels), _natural_clothing_state(row.get("hardcore_clothing_state")), hard_cast_prose, f"set in {hard_scene}" if hard_scene else "", _expression_phrase(hard_expression), - _composition_phrase(hard_composition, hard_action, detail_density=hard_detail_density), + _composition_phrase(hard_output_composition, hard_action, detail_density=hard_detail_density), hard_camera, hard_style if detail_level != "concise" else "", ] diff --git a/prompt_builder.py b/prompt_builder.py index a6911eb..deefff3 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -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,