diff --git a/README.md b/README.md index 406b440..0bce9d7 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ 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. +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 +option fallback. Set it from `0.0` to `1.0` to make expression selection +character-driven. For configured casts, matching enabled slots emit +per-character expression text such as `Woman A has ...; Man A has ...`; Krea +formatting naturalizes those labels in pair prompts. + Slots are chainable through the `character_cast` input/output. In automatic label mode, the slot closest to the final generator becomes `A` for its gender, the next upstream slot becomes `B`, then `C`, and so on. Example: @@ -293,6 +301,11 @@ Options: - `hardcore_expression_intensity`: `0.0` is controlled, `0.5` is balanced hardcore, `1.0` strongly favors ahegao-style, drooling, fucked-out, climax, and messy orgasm expressions. +- `softcore_expression_enabled` and `hardcore_expression_enabled`: disable the + expression sentence for that half of the Insta/OF pair. The intensity values + are fallbacks; `SxCP Woman Slot` / `SxCP Man Slot` `expression_intensity` + overrides them when character slots are connected, and a disabled slot omits + that character's expression. - `platform_style`: `hybrid`, `instagram`, or `onlyfans`. - `continuity`: `same_creator_same_room` keeps the scene aligned while each output keeps its own pose/composition; `same_creator_new_scene` keeps the same @@ -320,6 +333,7 @@ The node keeps the original generator controls: `indigenous`, `mixed`, `asian`, or `white_asian`. Combined filter strings such as `latina+south_asian` are also accepted in config JSON. - `poses`: `standard` or `evocative`. +- `expression_enabled`: disable facial-expression text entirely for this row. - `expression_intensity`: `0.0` favors mild, neutral, controlled expressions; `0.5` favors balanced category expressions; `1.0` strongly favors the most intense expressions available in the selected category. This affects custom diff --git a/__init__.py b/__init__.py index a145c2a..903e18d 100644 --- a/__init__.py +++ b/__init__.py @@ -106,6 +106,7 @@ class SxCPPromptBuilder: "clothing": (["full", "minimal"], {"default": "full"}), "ethnicity": (ethnicity_choices(), {"default": "any"}), "poses": (["standard", "evocative"], {"default": "standard"}), + "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), "backside_bias": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), "figure": (["curvy", "balanced", "bombshell"], {"default": "curvy"}), @@ -141,6 +142,7 @@ class SxCPPromptBuilder: clothing, ethnicity, poses, + expression_enabled, expression_intensity, backside_bias, figure, @@ -168,6 +170,7 @@ class SxCPPromptBuilder: clothing=clothing, ethnicity=ethnicity, poses=poses, + expression_enabled=expression_enabled, expression_intensity=expression_intensity, backside_bias=backside_bias, figure=figure, @@ -418,6 +421,7 @@ class SxCPGenerationProfile: "profile": (generation_profile_choices(), {"default": "balanced"}), "clothing_override": (["profile_default", "full", "minimal"], {"default": "profile_default"}), "poses_override": (["profile_default", "standard", "evocative"], {"default": "profile_default"}), + "expression_enabled": ("BOOLEAN", {"default": True}), "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "backside_bias": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), "minimal_clothing_ratio": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), @@ -436,6 +440,7 @@ class SxCPGenerationProfile: profile, clothing_override, poses_override, + expression_enabled, expression_intensity, backside_bias, minimal_clothing_ratio, @@ -446,6 +451,7 @@ class SxCPGenerationProfile: profile=profile, clothing_override=clothing_override, poses_override=poses_override, + expression_enabled=expression_enabled, expression_intensity=expression_intensity, backside_bias=backside_bias, minimal_clothing_ratio=minimal_clothing_ratio, @@ -453,7 +459,8 @@ class SxCPGenerationProfile: trigger_policy=trigger_policy, ) parsed = json.loads(config) - summary = f"{parsed['profile']}: {parsed['clothing']}, {parsed['poses']}, expression {parsed['expression_intensity']}" + expression_summary = "expression disabled" if not parsed.get("expression_enabled", True) else f"expression {parsed['expression_intensity']}" + summary = f"{parsed['profile']}: {parsed['clothing']}, {parsed['poses']}, {expression_summary}" return config, summary @@ -600,6 +607,8 @@ class SxCPCharacterSlot: "hair": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}), "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}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -627,6 +636,8 @@ class SxCPCharacterSlot: hair, eyes, descriptor_detail="auto", + expression_enabled=True, + expression_intensity=-1.0, character_cast="", ): result = build_character_slot_json( @@ -643,6 +654,8 @@ class SxCPCharacterSlot: hair=hair, eyes=eyes, descriptor_detail=descriptor_detail, + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, enabled=enabled, character_cast=character_cast or "", ) @@ -667,6 +680,8 @@ class SxCPWomanSlot: "hair": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}), "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}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -693,6 +708,8 @@ class SxCPWomanSlot: hair, eyes, descriptor_detail="auto", + expression_enabled=True, + expression_intensity=-1.0, character_cast="", ): result = build_character_slot_json( @@ -709,6 +726,8 @@ class SxCPWomanSlot: hair=hair, eyes=eyes, descriptor_detail=descriptor_detail, + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, enabled=enabled, character_cast=character_cast or "", ) @@ -732,6 +751,8 @@ class SxCPManSlot: "hair": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}), "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}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -757,6 +778,8 @@ class SxCPManSlot: hair, eyes, descriptor_detail="compact", + expression_enabled=True, + expression_intensity=-1.0, character_cast="", ): result = build_character_slot_json( @@ -773,6 +796,8 @@ class SxCPManSlot: hair=hair, eyes=eyes, descriptor_detail=descriptor_detail, + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, enabled=enabled, character_cast=character_cast or "", ) @@ -1005,6 +1030,8 @@ class SxCPInstaOFOptions: "hardcore_men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), "softcore_level": (["social_tease", "lingerie_tease", "implied_nude", "explicit_tease", "explicit_nude"], {"default": "lingerie_tease"}), "hardcore_level": (["explicit", "hardcore"], {"default": "hardcore"}), + "softcore_expression_enabled": ("BOOLEAN", {"default": True}), + "hardcore_expression_enabled": ("BOOLEAN", {"default": True}), "softcore_expression_intensity": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01}), "hardcore_expression_intensity": ("FLOAT", {"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.01}), "platform_style": (["hybrid", "instagram", "onlyfans"], {"default": "hybrid"}), @@ -1029,6 +1056,8 @@ class SxCPInstaOFOptions: hardcore_men_count, softcore_level, hardcore_level, + softcore_expression_enabled, + hardcore_expression_enabled, softcore_expression_intensity, hardcore_expression_intensity, platform_style, @@ -1046,6 +1075,8 @@ class SxCPInstaOFOptions: hardcore_men_count=hardcore_men_count, softcore_level=softcore_level, hardcore_level=hardcore_level, + softcore_expression_enabled=softcore_expression_enabled, + hardcore_expression_enabled=hardcore_expression_enabled, softcore_expression_intensity=softcore_expression_intensity, hardcore_expression_intensity=hardcore_expression_intensity, platform_style=platform_style, diff --git a/caption_naturalizer.py b/caption_naturalizer.py index 6221100..01f37ba 100644 --- a/caption_naturalizer.py +++ b/caption_naturalizer.py @@ -49,6 +49,18 @@ def _clean_text(value: Any) -> str: return text +def _is_false(value: Any) -> bool: + if isinstance(value, bool): + return value is False + if isinstance(value, str): + return value.strip().lower() in ("false", "0", "no", "off") + return False + + +def _expression_disabled(row: dict[str, Any]) -> bool: + return bool(row.get("expression_disabled")) or _is_false(row.get("expression_enabled", True)) + + def _cap_first(text: str) -> str: text = _clean_text(text).strip(" ,") return text[:1].upper() + text[1:] if text else "" @@ -362,7 +374,7 @@ def _single_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) - item = _clean_clothing(_row_value(row, "clothing", ("Clothing", "Erotic outfit"))) scene = _row_value(row, "scene_text", ("Scene", "Setting")) pose = _row_value(row, "pose", ("Pose",)) - expression = _row_value(row, "expression", ("Facial expression", "Facial expressions")) + expression = "" if _expression_disabled(row) else _row_value(row, "expression", ("Facial expression", "Facial expressions")) composition = _normalize_composition(_row_value(row, "composition", ("Composition",))) prop = _row_value(row, "prop", ("Prop/detail",)) style = _row_value(row, "style") if keep_style else "" @@ -431,7 +443,9 @@ def _couple_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) - pose = pose.replace(", affectionate and flirtatious but non-explicit", "") clothing = _clean_clothing(_row_value(row, "item", ITEM_LABELS) or _row_value(row, "clothing", ("Clothing",))) scene = _row_value(row, "scene_text", ("Scene", "Setting")) - expression = _row_value(row, "expression", ("Facial expressions", "Facial expression")) + expression = "" + if not _expression_disabled(row): + expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression")) composition = _normalize_composition(_row_value(row, "composition", ("Composition",))) style = _row_value(row, "style") if keep_style else "" @@ -466,7 +480,9 @@ def _configured_cast_from_row(row: dict[str, Any], detail_level: str, keep_style role_graph = _row_value(row, "role_graph", ("Role graph",)) item = _row_value(row, "item", ITEM_LABELS) scene = _row_value(row, "scene_text", ("Setting", "Scene")) - expression = _row_value(row, "expression", ("Facial expressions", "Facial expression")) + expression = "" + if not _expression_disabled(row): + expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression")) composition = _normalize_composition(_row_value(row, "composition", ("Composition",))) cast_descriptor_text = _row_value(row, "cast_descriptor_text", ("Characters", "Cast descriptors")) scene_kind = _row_value(row, "scene_kind") or "explicit adult sex scene" @@ -504,7 +520,9 @@ def _group_or_layout_from_row(row: dict[str, Any], detail_level: str, keep_style age = _row_value(row, "age", ("Ages",)) or _clean_text(row.get("age_band")) item = _clean_clothing(_row_value(row, "item", ITEM_LABELS) or _row_value(row, "clothing", ("Clothing",))) scene = _row_value(row, "scene_text", ("Scene", "Setting")) - expression = _row_value(row, "expression", ("Facial expressions", "Facial expression")) + expression = "" + if not _expression_disabled(row): + expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression")) composition = _normalize_composition(_row_value(row, "composition", ("Composition",))) style = _row_value(row, "style") if keep_style else "" diff --git a/examples/default_task_lanes_workflow.json b/examples/default_task_lanes_workflow.json index 4bcc628..3cecd84 100644 --- a/examples/default_task_lanes_workflow.json +++ b/examples/default_task_lanes_workflow.json @@ -41,7 +41,7 @@ "id": 3, "type": "SxCPGenerationProfile", "pos": [-1220, -300], - "size": [320, 226], + "size": [320, 250], "flags": {}, "order": 2, "mode": 0, @@ -51,7 +51,7 @@ {"name": "summary", "type": "STRING", "links": null, "slot_index": 1} ], "properties": {"Node name for S&R": "SxCPGenerationProfile"}, - "widgets_values": ["casual_clean", "profile_default", "profile_default", -1.0, -1.0, -1.0, -1.0, "profile_default"] + "widgets_values": ["casual_clean", "profile_default", "profile_default", true, -1.0, -1.0, -1.0, -1.0, "profile_default"] }, { "id": 4, @@ -210,7 +210,7 @@ "id": 12, "type": "SxCPInstaOFOptions", "pos": [-1220, 290], - "size": [360, 318], + "size": [360, 366], "flags": {}, "order": 11, "mode": 0, @@ -219,7 +219,7 @@ {"name": "options_json", "type": "STRING", "links": [10], "slot_index": 0} ], "properties": {"Node name for S&R": "SxCPInstaOFOptions"}, - "widgets_values": ["same_as_hardcore", "couple", 1, 1, "lingerie_tease", "hardcore", 0.45, 0.85, "hybrid", "same_creator_same_room", "partially_removed", "handheld_selfie", "from_camera_config", "compact"] + "widgets_values": ["same_as_hardcore", "couple", 1, 1, "lingerie_tease", "hardcore", true, true, 0.45, 0.85, "hybrid", "same_creator_same_room", "partially_removed", "handheld_selfie", "from_camera_config", "compact"] }, { "id": 13, diff --git a/krea_formatter.py b/krea_formatter.py index d9da375..8c98606 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -40,6 +40,18 @@ def _clean(value: Any) -> str: return text +def _is_false(value: Any) -> bool: + if isinstance(value, bool): + return value is False + if isinstance(value, str): + return value.strip().lower() in ("false", "0", "no", "off") + return False + + +def _expression_disabled(row: dict[str, Any]) -> bool: + return bool(row.get("expression_disabled")) or _is_false(row.get("expression_enabled", True)) + + def _sentence(text: str) -> str: text = _clean(text).strip(" ,;") if not text: @@ -1009,6 +1021,15 @@ def _appearance_phrase(row: dict[str, Any]) -> str: return ", ".join(_clean(part) for part in parts if _clean(part)) +def _expression_phrase(expression: Any) -> str: + expression = _clean(expression) + if not expression: + return "" + if ";" in expression or re.search(r"\b(?:Woman|Man) [A-Z] has\b|\bthe (?:woman|man) has\b", expression): + return f"Expressions: {expression}" + return f"with {expression}" + + def _camera_phrase(row: dict[str, Any]) -> str: directive = _clean(row.get("camera_directive")) if directive: @@ -1090,7 +1111,9 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) item = re.sub(r",?\s*(fashion editorial|resort) styling$", "", item, flags=re.IGNORECASE) scene = _row_value(row, "scene_text", ("Setting", "Scene")) or _clean(row.get("scene")) pose = _row_value(row, "pose", ("Sexual pose", "Pose")) - expression = _row_value(row, "expression", ("Facial expressions", "Facial expression")) + expression = "" + 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) camera = _camera_phrase(row) style = _style_phrase(row, style_mode) @@ -1111,6 +1134,7 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) cast_prose, cast_labels = _cast_prose(cast_descriptor_text) if not cast_labels and women_count == 1 and men_count == 1: cast_labels = ["Woman A", "Man A"] + expression = _natural_label_text(expression, cast_labels) role_graph = _sanitize_scene_text_for_cast(row.get("role_graph"), cast_labels) item = _sanitize_scene_text_for_cast(item, cast_labels) role_graph = _natural_label_text(role_graph, cast_labels) @@ -1123,7 +1147,7 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) 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 "", - f"Facial expressions are {expression}" if expression else "", + _expression_phrase(expression), _composition_phrase(composition, action, "The image is framed as"), camera, style if detail_level != "concise" else "", @@ -1228,6 +1252,19 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) 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( + _clean(soft.get("character_expression_text")) or _clean(soft.get("expression")), + soft_labels, + ) + hard_expression = "" + if not _expression_disabled(hard): + hard_expression = _natural_label_text( + _clean(hard.get("character_expression_text")) or _clean(hard.get("expression")), + hard_labels, + ) + soft_parts = [ soft_cast_prose, soft_cast_presence, @@ -1235,7 +1272,7 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) partner_pose, f"wearing {soft.get('item')}" if soft.get("item") else "", f"{soft.get('pose')}" if soft.get("pose") else "", - f"with {soft.get('expression')}" if soft.get("expression") 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 "", soft_camera, @@ -1246,7 +1283,7 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) _natural_clothing_state(row.get("hardcore_clothing_state")), hard_cast_prose, f"set in {hard_scene}" if hard_scene else "", - f"with {hard.get('expression')}" if hard.get("expression") else "", + _expression_phrase(hard_expression), _composition_phrase(hard_composition, hard_action), hard_camera, hard_style if detail_level != "concise" else "", diff --git a/prompt_builder.py b/prompt_builder.py index 56fe857..fcb6511 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -890,6 +890,7 @@ GENERATION_PROFILE_PRESETS = { "balanced": { "clothing": "full", "poses": "standard", + "expression_enabled": True, "expression_intensity": 0.5, "backside_bias": 0.0, "minimal_clothing_ratio": -1.0, @@ -900,6 +901,7 @@ GENERATION_PROFILE_PRESETS = { "casual_clean": { "clothing": "full", "poses": "standard", + "expression_enabled": True, "expression_intensity": 0.35, "backside_bias": 0.0, "minimal_clothing_ratio": -1.0, @@ -910,6 +912,7 @@ GENERATION_PROFILE_PRESETS = { "evocative_softcore": { "clothing": "minimal", "poses": "evocative", + "expression_enabled": True, "expression_intensity": 0.65, "backside_bias": 0.2, "minimal_clothing_ratio": -1.0, @@ -920,6 +923,7 @@ GENERATION_PROFILE_PRESETS = { "hardcore_intense": { "clothing": "minimal", "poses": "evocative", + "expression_enabled": True, "expression_intensity": 0.9, "backside_bias": 0.0, "minimal_clothing_ratio": -1.0, @@ -930,6 +934,7 @@ GENERATION_PROFILE_PRESETS = { "krea2_friendly": { "clothing": "full", "poses": "standard", + "expression_enabled": True, "expression_intensity": 0.55, "backside_bias": 0.0, "minimal_clothing_ratio": -1.0, @@ -940,6 +945,7 @@ GENERATION_PROFILE_PRESETS = { "flux_original": { "clothing": "full", "poses": "standard", + "expression_enabled": True, "expression_intensity": 0.5, "backside_bias": 0.0, "minimal_clothing_ratio": -1.0, @@ -1039,6 +1045,7 @@ def build_generation_profile_json( minimal_clothing_ratio: float = -1.0, standard_pose_ratio: float = -1.0, trigger_policy: str = "profile_default", + expression_enabled: bool = True, ) -> str: profile = profile if profile in GENERATION_PROFILE_PRESETS else "balanced" config = dict(GENERATION_PROFILE_PRESETS[profile]) @@ -1046,6 +1053,7 @@ def build_generation_profile_json( config["clothing"] = clothing_override if poses_override in ("standard", "evocative"): config["poses"] = poses_override + config["expression_enabled"] = not _is_false(expression_enabled) if float(expression_intensity) >= 0: config["expression_intensity"] = _clamped_float(expression_intensity, config["expression_intensity"]) if float(backside_bias) >= 0: @@ -1079,6 +1087,7 @@ def _parse_generation_profile(profile_config: str | dict[str, Any] | None) -> di parsed.update(raw) parsed["clothing"] = parsed["clothing"] if parsed.get("clothing") in ("full", "minimal") else "full" parsed["poses"] = parsed["poses"] if parsed.get("poses") in ("standard", "evocative") else "standard" + parsed["expression_enabled"] = not _is_false(parsed.get("expression_enabled", True)) parsed["expression_intensity"] = _clamped_float(parsed.get("expression_intensity"), 0.5) parsed["backside_bias"] = _clamped_float(parsed.get("backside_bias"), 0.0) parsed["minimal_clothing_ratio"] = _clamped_float(parsed.get("minimal_clothing_ratio"), -1.0, -1.0, 1.0) @@ -1325,6 +1334,54 @@ def _format(template: str, context: dict[str, Any]) -> str: return template.format_map(safe_context) +def _clean_prompt_punctuation(text: str) -> str: + text = re.sub(r"\s+", " ", str(text or "")).strip() + text = re.sub(r"\s+([,.;:])", r"\1", text) + text = re.sub(r"(?:,\s*){2,}", ", ", text) + text = re.sub(r"\.\s*\.", ".", text) + text = re.sub(r":\s*\.", ".", text) + return text.strip() + + +def _strip_expression_text(text: str, expression: Any = "") -> str: + text = str(text or "") + if not text: + return "" + text = re.sub(r"\s*Facial expressions?:\s*[^.]*\.\s*", " ", text, flags=re.IGNORECASE) + text = re.sub(r",\s*one with [^,]+ and the other with [^,]+(?=,)", "", text, flags=re.IGNORECASE) + text = re.sub(r",\s*a lively mix of expressions from [^,]+(?=,)", "", text, flags=re.IGNORECASE) + text = re.sub(r"\s+with\s+(?:an?|the)\s+[^,]*expression(?=,)", "", text, flags=re.IGNORECASE) + expression_text = str(expression or "").strip() + if expression_text: + for part in [piece.strip() for piece in expression_text.split(";") if piece.strip()]: + escaped = re.escape(part) + text = re.sub(rf",\s*{escaped}(?=,)", "", text, flags=re.IGNORECASE) + text = re.sub(rf"\s+with\s+(?:an?|the)?\s*{escaped}", "", text, flags=re.IGNORECASE) + return _clean_prompt_punctuation(text) + + +def _disable_row_expression(row: dict[str, Any], source: str = "disabled") -> dict[str, Any]: + previous_expression = row.get("expression", "") + row["prompt"] = _strip_expression_text(row.get("prompt", ""), previous_expression) + row["caption"] = _strip_expression_text(row.get("caption", ""), previous_expression) + row["expression"] = "" + row["shared_expression"] = "" + row["character_expressions"] = [] + row["character_expression_text"] = "" + row["expression_enabled"] = False + row["expression_disabled"] = True + row["expression_intensity"] = None + row["expression_intensity_source"] = source + return row + + +def _labeled_expression_sentence(label: str, expression: Any) -> str: + expression = str(expression or "").strip() + if not expression: + return "" + return f"{label}: {expression}. " + + def _prepend_trigger(prompt: str, trigger: str, enabled: bool) -> str: trigger = trigger.strip() if not enabled or not trigger: @@ -1801,6 +1858,111 @@ def _normalize_descriptor_detail(value: Any) -> str: return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto" +def _normalize_slot_expression_intensity(value: Any) -> float: + try: + intensity = float(value) + except (TypeError, ValueError): + return -1.0 + if intensity < 0: + return -1.0 + return _clamped_float(intensity, 0.5) + + +def _slot_expression_enabled(slot: dict[str, Any] | None) -> bool: + if not slot: + return True + return not _is_false(slot.get("expression_enabled", True)) + + +def _slot_expression_intensity(slot: dict[str, Any] | None) -> float | None: + if not slot or not _slot_expression_enabled(slot): + return None + intensity = _normalize_slot_expression_intensity(slot.get("expression_intensity")) + return intensity if intensity >= 0 else None + + +def _mean(values: list[float]) -> float: + return sum(values) / len(values) + + +def _cast_expression_intensity_override( + fallback: float, + label_map: dict[str, dict[str, Any]], + women_count: int, + men_count: int, +) -> tuple[float | None, str]: + groups: list[tuple[str, list[str]]] = [ + ("women", [f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))]), + ("men", [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]), + ] + all_values: list[float] = [] + matching_slots: list[dict[str, Any]] = [] + for group_name, labels in groups: + values: list[float] = [] + value_labels: list[str] = [] + for label in labels: + slot = label_map.get(label) + if slot: + matching_slots.append(slot) + value = _slot_expression_intensity(slot) + if value is not None: + values.append(value) + value_labels.append(label) + all_values.append(value) + if values: + if len(values) == 1: + return values[0], f"character_slot:{value_labels[0]}" + return _mean(values), f"character_slots:{group_name}" + if all_values: + return _mean(all_values), "character_slots:cast" + if matching_slots and all(not _slot_expression_enabled(slot) for slot in matching_slots): + return None, "character_slots:disabled" + return fallback, "input" + + +def _character_expression_entries( + rng: random.Random, + expression_pool: list[Any], + fallback_intensity: float, + label_map: dict[str, dict[str, Any]], + women_count: int, + men_count: int, +) -> list[str]: + labels = [ + *[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))], + *[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))], + ] + expressions: list[str] = [] + used: set[str] = set() + for label in labels: + slot = label_map.get(label) + if not slot: + continue + if not _slot_expression_enabled(slot): + continue + intensity = _slot_expression_intensity(slot) + if intensity is None: + intensity = fallback_intensity + entries = _compatible_entries( + _expression_entries_for_intensity(expression_pool, intensity), + women_count, + men_count, + ) + if not entries: + continue + choice = "" + for _attempt in range(5): + candidate = _choose_text(rng, entries) + if candidate not in used: + choice = candidate + break + if not choice: + choice = _choose_text(rng, entries) + used.add(choice) + expressions.append(f"{label} has {choice}") + return expressions + + def _descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str: detail = _normalize_descriptor_detail(descriptor_detail) if detail != "auto": @@ -1883,6 +2045,8 @@ 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")), + "expression_enabled": not _is_false(slot.get("expression_enabled", True)), + "expression_intensity": _normalize_slot_expression_intensity(slot.get("expression_intensity")), } normalized["summary"] = _character_slot_summary(normalized) return normalized @@ -1927,6 +2091,12 @@ def _character_slot_summary(slot: dict[str, Any]) -> str: f"body={slot.get('body', 'random')}", f"detail={slot.get('descriptor_detail', 'auto')}", ] + if not _slot_expression_enabled(slot): + parts.append("expression=disabled") + else: + expression_intensity = _slot_expression_intensity(slot) + if expression_intensity is not None: + parts.append(f"expression={expression_intensity:.2f}") for key in ("body_phrase", "skin", "hair", "eyes"): value = slot.get(key) if value: @@ -1948,6 +2118,8 @@ def build_character_slot_json( hair: str = "", eyes: str = "", descriptor_detail: str = "auto", + expression_enabled: bool = True, + expression_intensity: float = -1.0, enabled: bool = True, character_cast: str | dict[str, Any] | list[Any] | None = "", ) -> dict[str, str]: @@ -1967,6 +2139,8 @@ def build_character_slot_json( "hair": hair, "eyes": eyes, "descriptor_detail": descriptor_detail, + "expression_enabled": expression_enabled, + "expression_intensity": expression_intensity, } ) slots = existing_slots + ([slot] if enabled else []) @@ -2049,6 +2223,10 @@ def _context_from_character_slot( if value: context[key] = value context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail")) + context["expression_enabled"] = _slot_expression_enabled(slot) + expression_intensity = _slot_expression_intensity(slot) + if expression_intensity is not None: + context["expression_intensity"] = expression_intensity context["subject_type"] = subject_type context["subject"] = subject_type context["subject_phrase"] = subject_type @@ -2084,9 +2262,11 @@ def _apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any] "eyes", "figure", "descriptor_detail", + "expression_enabled", + "expression_intensity", ): value = context.get(key) - if value: + if value is not None and value != "": row[key] = value if context.get("age"): row["age_band"] = context["age"] @@ -2997,6 +3177,7 @@ def _build_custom_row( men_count: int, seed: int, seed_config: dict[str, int], + expression_enabled: bool, expression_intensity: float, character_profile: str | dict[str, Any] | None = None, character_cast: str | dict[str, Any] | list[Any] | None = None, @@ -3063,6 +3244,29 @@ def _build_custom_row( role_graph = _role_graph(role_rng, subcategory, context, item_axis_values) cast_descriptors: list[str] = [] cast_descriptor_text = "" + expression_intensity_source = "input" + expression_disabled = not bool(expression_enabled) + if expression_disabled: + expression_intensity_source = "disabled" + elif subject_type in ("woman", "man") and applied_slot: + slot_label = "Woman A" if subject_type == "woman" else "Man A" + if not _slot_expression_enabled(applied_slot): + expression_disabled = True + expression_intensity_source = f"character_slot:{slot_label}:disabled" + else: + slot_expression_intensity = _slot_expression_intensity(applied_slot) + if slot_expression_intensity is not None: + expression_intensity = slot_expression_intensity + expression_intensity_source = f"character_slot:{slot_label}" + elif subject_type == "configured_cast" and character_slots: + expression_intensity, expression_intensity_source = _cast_expression_intensity_override( + expression_intensity, + character_slot_map, + women_count, + men_count, + ) + if expression_intensity is None: + expression_disabled = True if subject_type == "configured_cast" and character_slots: cast_descriptors, _descriptor_slots = _cast_descriptor_entries( seed_config, @@ -3082,16 +3286,35 @@ def _build_custom_row( pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text( pose_rng, _compatible_entries(_pose_pool(category, subcategory, item, subject_type, poses), women_count, men_count) )) - expression_entries = _compatible_entries( - _expression_entries_for_intensity(_expression_pool(category, subcategory, item), expression_intensity), - women_count, - men_count, - ) - expression = _choose_text(expression_rng, expression_entries) - if subject_type in ("couple", "group") and ";" not in expression: - secondary_expression = _choose_distinct_text(expression_rng, expression_entries, expression) - if secondary_expression: - expression = f"{expression}; {secondary_expression}" + expression_pool = _expression_pool(category, subcategory, item) + if expression_disabled: + expression = "" + else: + expression_entries = _compatible_entries( + _expression_entries_for_intensity(expression_pool, expression_intensity), + women_count, + men_count, + ) + expression = _choose_text(expression_rng, expression_entries) + if subject_type in ("couple", "group") and ";" not in expression: + secondary_expression = _choose_distinct_text(expression_rng, expression_entries, expression) + if secondary_expression: + expression = f"{expression}; {secondary_expression}" + shared_expression = expression + character_expressions: list[str] = [] + character_expression_text = "" + if not expression_disabled and subject_type == "configured_cast" and character_slots: + character_expressions = _character_expression_entries( + expression_rng, + expression_pool, + expression_intensity, + character_slot_map, + women_count, + men_count, + ) + character_expression_text = "; ".join(character_expressions) + if character_expression_text: + expression = character_expression_text composition = _choose_text( composition_rng, _compatible_entries(_composition_pool(category, subcategory, item, subject_type), women_count, men_count), @@ -3124,7 +3347,13 @@ def _build_custom_row( "scene_slug": scene_slug, "pose": pose, "expression": expression, + "shared_expression": shared_expression, + "character_expressions": character_expressions, + "character_expression_text": character_expression_text, + "expression_enabled": not expression_disabled, + "expression_disabled": expression_disabled, "expression_intensity": expression_intensity, + "expression_intensity_source": expression_intensity_source, "composition": composition, "composition_prompt": _composition_prompt(composition), "role_graph": role_graph, @@ -3191,6 +3420,11 @@ def _build_custom_row( "seed_config": seed_config, "content_seed_axis": content_axis, "role_graph": role_graph, + "shared_expression": shared_expression, + "character_expressions": character_expressions, + "character_expression_text": character_expression_text, + "expression_enabled": not expression_disabled, + "expression_disabled": expression_disabled, "cast_summary": context.get("cast_summary", ""), "cast_descriptors": cast_descriptors, "cast_descriptor_text": cast_descriptor_text, @@ -3204,11 +3438,15 @@ def _build_custom_row( "character_slot": applied_slot, "character_slot_status": slot_status, "character_cast_slots": character_slots, + "expression_intensity": expression_intensity, + "expression_intensity_source": expression_intensity_source, "source": "json_category", } ) if context.get("figure"): row["figure"] = context["figure"] + if expression_disabled: + row = _disable_row_expression(row, expression_intensity_source) return row @@ -3238,6 +3476,7 @@ def build_prompt( expression_intensity: float = 0.5, character_profile: str | dict[str, Any] | None = None, character_cast: str | dict[str, Any] | list[Any] | None = None, + expression_enabled: bool = True, ) -> dict[str, Any]: apply_pool_extensions() row_number = max(1, int(row_number)) @@ -3247,6 +3486,7 @@ def build_prompt( ethnicity = ethnicity if ethnicity == "any" or ethnicity in ETHNICITY_FILTER_CHOICES or "+" in str(ethnicity) else "any" poses = poses if poses in ("standard", "evocative") else "standard" figure = figure if figure in ("curvy", "balanced", "bombshell") else "curvy" + expression_enabled = not _is_false(expression_enabled) minimal_ratio = _ratio_or_none(minimal_clothing_ratio) pose_ratio = _ratio_or_none(standard_pose_ratio) expression_intensity = _clamped_float(expression_intensity, 0.5) @@ -3300,11 +3540,14 @@ def build_prompt( int(men_count), seed, parsed_seed_config, + expression_enabled, expression_intensity, character_profile, character_cast, ) + if not expression_enabled: + row = _disable_row_expression(row, "disabled") if extra_positive.strip(): row["prompt"] = f"{row['prompt'].rstrip()} {extra_positive.strip()}" row = _apply_camera_config(row, camera_config) @@ -3312,7 +3555,8 @@ def build_prompt( row["prompt"] = _prepend_trigger(row["prompt"], active_trigger, bool(prepend_trigger_to_prompt)) row["negative_prompt"] = _combined_negative(row.get("negative_prompt", g.NEGATIVE_PROMPT), extra_negative) row["trigger"] = active_trigger - row["expression_intensity"] = expression_intensity + row.setdefault("expression_intensity", expression_intensity) + row.setdefault("expression_intensity_source", "input") return row @@ -3344,6 +3588,7 @@ def build_prompt_from_configs( clothing=profile["clothing"], ethnicity=filters["ethnicity"], poses=profile["poses"], + expression_enabled=profile["expression_enabled"], expression_intensity=profile["expression_intensity"], backside_bias=profile["backside_bias"], figure=filters["figure"], @@ -3538,6 +3783,8 @@ def build_insta_of_options_json( camera_detail: str = "compact", softcore_expression_intensity: float = 0.45, hardcore_expression_intensity: float = 0.85, + softcore_expression_enabled: bool = True, + hardcore_expression_enabled: bool = True, ) -> str: return json.dumps( { @@ -3553,6 +3800,8 @@ def build_insta_of_options_json( "softcore_camera_mode": softcore_camera_mode, "hardcore_camera_mode": hardcore_camera_mode, "camera_detail": camera_detail, + "softcore_expression_enabled": not _is_false(softcore_expression_enabled), + "hardcore_expression_enabled": not _is_false(hardcore_expression_enabled), "softcore_expression_intensity": _clamped_float(softcore_expression_intensity, 0.45), "hardcore_expression_intensity": _clamped_float(hardcore_expression_intensity, 0.85), }, @@ -3575,6 +3824,8 @@ def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[s "softcore_camera_mode": "handheld_selfie", "hardcore_camera_mode": "from_camera_config", "camera_detail": "compact", + "softcore_expression_enabled": True, + "hardcore_expression_enabled": True, "softcore_expression_intensity": 0.45, "hardcore_expression_intensity": 0.85, } @@ -3608,6 +3859,8 @@ def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[s ): parsed["hardcore_camera_mode"] = defaults["hardcore_camera_mode"] parsed["camera_detail"] = parsed["camera_detail"] if parsed["camera_detail"] in CAMERA_DETAIL_CHOICES else defaults["camera_detail"] + parsed["softcore_expression_enabled"] = not _is_false(parsed.get("softcore_expression_enabled", True)) + parsed["hardcore_expression_enabled"] = not _is_false(parsed.get("hardcore_expression_enabled", True)) parsed["softcore_expression_intensity"] = _clamped_float( parsed.get("softcore_expression_intensity"), defaults["softcore_expression_intensity"], @@ -3806,6 +4059,22 @@ def build_insta_of_pair( soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key) soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311) soft_person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number) + soft_expression_women_count = hard_women_count if options["softcore_cast"] == "same_as_hardcore" else 1 + soft_expression_men_count = hard_men_count if options["softcore_cast"] == "same_as_hardcore" else 0 + soft_expression_enabled = bool(options["softcore_expression_enabled"]) + soft_expression_intensity = options["softcore_expression_intensity"] + soft_expression_intensity_source = "input" + if soft_expression_enabled: + soft_expression_intensity, soft_expression_intensity_source = _cast_expression_intensity_override( + options["softcore_expression_intensity"], + character_slot_map, + soft_expression_women_count, + soft_expression_men_count, + ) + if soft_expression_intensity is None: + soft_expression_enabled = False + else: + soft_expression_intensity_source = "disabled" primary_slot_context = None primary_slot = character_slot_map.get("Woman A") if primary_slot: @@ -3841,14 +4110,18 @@ def build_insta_of_pair( seed_config=parsed_seed_config, women_count=1, men_count=0, - expression_intensity=options["softcore_expression_intensity"], + expression_enabled=soft_expression_enabled, + expression_intensity=soft_expression_intensity, character_profile="" if primary_slot else character_profile or "", character_cast="", ) + soft_row["expression_intensity_source"] = soft_expression_intensity_source if primary_slot_context: soft_row = _apply_character_context_to_row(soft_row, primary_slot_context) soft_row["character_slot"] = primary_slot soft_row["character_slot_status"] = "applied:Woman A" + if not soft_expression_enabled: + soft_row = _disable_row_expression(soft_row, soft_expression_intensity_source) soft_row["item"] = _insta_of_softcore_outfit(soft_content_rng, softcore_level_key) soft_row["pose"] = _insta_of_softcore_pose(soft_content_rng, softcore_level_key) soft_row["item_label"] = "Insta/OF softcore outfit" @@ -3876,6 +4149,7 @@ def build_insta_of_pair( seed_config=parsed_seed_config, women_count=hard_women_count, men_count=hard_men_count, + expression_enabled=options["hardcore_expression_enabled"], expression_intensity=options["hardcore_expression_intensity"], character_cast=character_cast or "", ) @@ -3959,7 +4233,8 @@ def build_insta_of_pair( f"{soft_cast_presence}" f"{soft_cast_styling_sentence}" f"Outfit: {soft_row['item']}. Pose: {soft_row['pose']}. Setting: {soft_row['scene_text']}. " - f"Facial expression: {soft_row['expression']}. Composition: {soft_row['composition']}. " + f"{_labeled_expression_sentence('Facial expression', soft_row.get('expression'))}" + f"Composition: {soft_row['composition']}. " f"{soft_camera_sentence}" "Keep the softcore version seductive, creator-shot, and non-explicit. " f"{soft_row['positive_suffix']}." @@ -3971,7 +4246,9 @@ def build_insta_of_pair( "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}. Facial expressions: {hard_row['expression']}. Composition: {hard_composition}. " + f"Setting: {hard_scene}. " + f"{_labeled_expression_sentence('Facial expressions', hard_row.get('expression'))}" + f"Composition: {hard_composition}. " f"{hard_camera_sentence}" f"{hard_row['positive_suffix']}." )