From 51d351679fa890b6edb47042333861506045c165 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 10:57:46 +0200 Subject: [PATCH] Improve paired cast continuity and wording --- README.md | 12 ++++++ caption_naturalizer.py | 56 ++++++++++++++++++++++++++- generate_prompt_batches.py | 14 ++++++- krea_formatter.py | 33 ++++++++++++++-- prompt_builder.py | 77 +++++++++++++++++++++++++++++++++++++- 5 files changed, 184 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 70c4a57..7790c48 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ more natural language. Connect the prompt builder's `metadata_json` output to `prompt`; in that case the node falls back to prompt-label parsing or comma-tag cleanup. +When connected to `SxCP Insta/OF Prompt Pair` metadata, the naturalizer emits a +single combined natural caption with the shared descriptor plus separate +softcore and hardcore version descriptions. It uses the final selected +expression and composition from the generated rows, including any expression +pool and intensity settings. + Naturalizer controls: - `input_hint`: `auto`, `metadata_json`, or `caption_or_prompt`. @@ -100,6 +106,12 @@ shared primary creator descriptor, then returns both a softcore prompt and a hardcore prompt from that same descriptor. This is useful when you want the same person/look/scene continuity but need two different prompt strengths. +When the hardcore cast includes partners, pair mode also creates deterministic +shared cast descriptors such as `woman A / primary creator` and `man A`. Use +`softcore_cast=same_as_hardcore`, `hardcore_cast=couple`, and +`continuity=same_creator_same_room` when you want a soft prompt with the same +woman, man, and location as the hardcore prompt. + It outputs: - `softcore_prompt` diff --git a/caption_naturalizer.py b/caption_naturalizer.py index c75cc20..0df719f 100644 --- a/caption_naturalizer.py +++ b/caption_naturalizer.py @@ -180,6 +180,18 @@ def _clean_clothing(text: str) -> str: return text.strip(" ,") +def _body_phrase(body: Any, figure_note: Any = "") -> str: + body = _clean_text(body) + figure_note = _clean_text(figure_note) + if not body: + return figure_note + if not figure_note: + return f"{body} figure" + if "figure" in figure_note.lower(): + return f"{body} build and {figure_note}" + return f"{body} figure with {figure_note}" + + def _single_caption_front(row: dict[str, Any]) -> dict[str, str]: caption = _clean_text(row.get("caption")) if not caption: @@ -192,7 +204,7 @@ def _single_caption_front(row: dict[str, Any]) -> dict[str, str]: if not body_phrase: body = _clean_text(row.get("body_type") or row.get("body")) figure = _clean_text(row.get("figure")) - body_phrase = f"{body} figure with {figure}" if body and figure else f"{body} figure".strip() + body_phrase = _body_phrase(body, figure) front = f"{subject}, {age}, {body_phrase}, " if subject in ("woman", "man") and age and body_phrase and caption.startswith(front): try: @@ -290,7 +302,7 @@ def _single_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) - if not body_phrase: body = _clean_text(row.get("body_type") or row.get("body") or "") figure = _clean_text(row.get("figure")) - body_phrase = f"{body} figure with {figure}" if body and figure else f"{body} figure".strip() + body_phrase = _body_phrase(body, figure) skin = _row_value(row, "skin") or caption_front.get("caption_skin", "") hair = _row_value(row, "hair") or caption_front.get("caption_hair", "") @@ -454,8 +466,48 @@ def _group_or_layout_from_row(row: dict[str, Any], detail_level: str, keep_style return _join_sentences(parts), "metadata(group_layout)" +def _insta_of_pair_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None: + if _clean_text(row.get("mode")).lower() != "insta/of": + return None + soft_row = row.get("softcore_row") + hard_row = row.get("hardcore_row") + if not isinstance(soft_row, dict) or not isinstance(hard_row, dict): + return None + + hard_row_for_text = dict(hard_row) + options = row.get("options") + if isinstance(options, dict) and options.get("continuity") == "same_creator_same_room": + if soft_row.get("scene_text"): + hard_row_for_text["scene_text"] = soft_row["scene_text"] + if soft_row.get("composition"): + hard_row_for_text["composition"] = soft_row["composition"] + + soft_text, _soft_method = _metadata_to_prose(soft_row, detail_level, keep_style) + hard_text, _hard_method = _metadata_to_prose(hard_row_for_text, detail_level, keep_style) + descriptor = _clean_text(row.get("shared_descriptor")) + cast_descriptors = row.get("shared_cast_descriptors") + if isinstance(cast_descriptors, list): + cast_descriptor_text = _human_join([_clean_text(item) for item in cast_descriptors if _clean_text(item)]) + else: + cast_descriptor_text = _clean_text(cast_descriptors) + + parts = [] + if cast_descriptor_text: + parts.append(f"The shared cast descriptors are {cast_descriptor_text}") + elif descriptor: + parts.append(f"The shared creator descriptor is {descriptor}") + if soft_text: + parts.append(f"Softcore version: {soft_text}") + if hard_text: + parts.append(f"Hardcore version: {hard_text}") + if not parts: + return None + return _join_sentences(parts), "metadata(insta_of_pair)" + + def _metadata_to_prose(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str]: for builder in ( + _insta_of_pair_from_row, _configured_cast_from_row, _single_from_row, _couple_from_row, diff --git a/generate_prompt_batches.py b/generate_prompt_batches.py index 5817853..fe94f81 100755 --- a/generate_prompt_batches.py +++ b/generate_prompt_batches.py @@ -2823,6 +2823,18 @@ def figure_pool(mode: str) -> list: return FIGURE_CURVY # 'curvy' default: voluptuous-leaning mix +def make_body_phrase(body: str, figure_note: str = "") -> str: + body = str(body or "").strip() + figure_note = str(figure_note or "").strip() + if not body: + return figure_note + if not figure_note: + return f"{body} figure" + if "figure" in figure_note.lower(): + return f"{body} build and {figure_note}" + return f"{body} figure with {figure_note}" + + def choose_woman(rng: random.Random, ethnicity: str = "any", no_plus: bool = False, no_black: bool = False): young = by_ethnicity(YOUNG_WOMEN, ethnicity) mature = by_ethnicity(MATURE_WOMEN, ethnicity) @@ -2889,7 +2901,7 @@ def make_single(index: int, batch: int, rng: random.Random, gender: str, expr_de subject, age, body, skin, hair, eyes = choose(rng, men_pool) clothes = choose(rng, MEN_CLOTHES_MINIMAL if minimal else MEN_CLOTHES) figure_note = "" - body_phrase = f"{body} figure with {figure_note}" if figure_note else f"{body} figure" + body_phrase = make_body_phrase(body, figure_note) scene_slug, scene = choose(rng, SCENES) if poses == "evocative": diff --git a/krea_formatter.py b/krea_formatter.py index d81a3fc..b048154 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -118,6 +118,18 @@ def _row_value(row: dict[str, Any], key: str, labels: tuple[str, ...] = ()) -> s return "" +def _body_phrase(body: Any, figure_note: Any = "") -> str: + body = _clean(body) + figure_note = _clean(figure_note) + if not body: + return figure_note + if not figure_note: + return f"{body} figure" + if "figure" in figure_note.lower(): + return f"{body} build and {figure_note}" + return f"{body} figure with {figure_note}" + + def _single_caption_front(row: dict[str, Any]) -> dict[str, str]: caption = _strip_trigger(_clean(row.get("caption")), False) if not caption: @@ -128,7 +140,7 @@ def _single_caption_front(row: dict[str, Any]) -> dict[str, str]: if not body: body_type = _clean(row.get("body_type") or row.get("body")) figure = _clean(row.get("figure")) - body = f"{body_type} figure with {figure}" if body_type and figure else f"{body_type} figure".strip() + body = _body_phrase(body_type, figure) front = f"{subject}, {age}, {body}, " if subject in ("woman", "man") and age and body and caption.startswith(front): try: @@ -260,6 +272,11 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) -> tuple[str, str, str, str]: descriptor = _clean(row.get("shared_descriptor")) + cast_descriptors = row.get("shared_cast_descriptors") + if isinstance(cast_descriptors, list): + cast_descriptor_text = "; ".join(_clean(item) for item in cast_descriptors if _clean(item)) + else: + cast_descriptor_text = _clean(cast_descriptors) soft = row.get("softcore_row") if isinstance(row.get("softcore_row"), dict) else {} hard = row.get("hardcore_row") if isinstance(row.get("hardcore_row"), dict) else {} soft_camera = _clean(row.get("softcore_camera_directive")) or _camera_phrase(soft) @@ -274,9 +291,18 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) hard_cast_text = _clean(hard.get("cast_summary")) or ( f"{hard_cast} adult women and {hard_men} adult men" if hard_cast or hard_men else "" ) + 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 = soft.get("composition") if same_room and soft.get("composition") else hard.get("composition") + soft_cast_descriptor_text = ( + cast_descriptor_text + if options.get("softcore_cast") == "same_as_hardcore" + else f"Woman A / primary creator: {descriptor}" + ) soft_parts = [ f"A visibly adult creator, {descriptor}", + f"Shared cast descriptors: {soft_cast_descriptor_text}" if soft_cast_descriptor_text else "", f"shown in a {soft_level or 'softcore'} Insta/OF creator image", f"wearing {soft.get('item')}" if soft.get("item") else "", f"{soft.get('pose')}" if soft.get("pose") else "", @@ -288,12 +314,13 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) ] hard_parts = [ f"The same visibly adult creator, {descriptor}, is the visually central woman in a consensual explicit adult {hard_level or 'hardcore'} scene", + f"Shared cast descriptors: {cast_descriptor_text}" if cast_descriptor_text else "", f"all participants are 21+ and visibly adult; the cast includes {hard_cast_text}" if hard_cast_text else "all participants are 21+ and visibly adult", _clean(hard.get("role_graph")), f"The sexual action is {hard.get('item')}" if hard.get("item") else "", - f"set in {row.get('hardcore_row', {}).get('scene_text') or hard.get('scene_text')}" if hard.get("scene_text") else "", + f"set in {hard_scene}" if hard_scene else "", f"with {hard.get('expression')}" if hard.get("expression") else "", - f"framed as {hard.get('composition')}" if hard.get("composition") else "", + f"framed as {hard_composition}" if hard_composition else "", hard_camera, hard_style if detail_level != "concise" else "", ] diff --git a/prompt_builder.py b/prompt_builder.py index c96bad4..4c6e810 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1079,6 +1079,18 @@ def _merged_field(category: dict[str, Any], subcategory: dict[str, Any], item: A return default +def _body_phrase(body: Any, figure_note: Any = "") -> str: + body = str(body or "").strip() + figure_note = str(figure_note or "").strip() + if not body: + return figure_note + if not figure_note: + return f"{body} figure" + if "figure" in figure_note.lower(): + return f"{body} build and {figure_note}" + return f"{body} figure with {figure_note}" + + def _appearance_for_subject( rng: random.Random, subject_type: str, @@ -1116,7 +1128,7 @@ def _appearance_for_subject( "skin": skin, "hair": hair, "eyes": eyes, - "body_phrase": f"{body} figure with {figure_note}", + "body_phrase": _body_phrase(body, figure_note), "figure": figure_note, } @@ -1187,7 +1199,7 @@ def _configured_cast_context(women_count: int, men_count: int) -> dict[str, str] def _lettered(prefix: str, count: int) -> list[str]: letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - return [f"{prefix} {letters[index]}" for index in range(max(0, count))] + return [f"{prefix.capitalize()} {letters[index]}" for index in range(max(0, count))] def _pick_distinct(rng: random.Random, items: list[str], count: int) -> list[str]: @@ -2017,6 +2029,46 @@ def _insta_of_descriptor(row: dict[str, Any]) -> str: return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) +def _insta_of_descriptor_from_context(context: dict[str, Any]) -> str: + age = str(context.get("age") or "").strip() + age = " ".join(age.split()) + age = age.removesuffix(" adults").removesuffix(" adult").strip() + subject = str(context.get("subject") or context.get("subject_type") or "person").strip() + pieces = [ + f"{age} adult {subject}".strip(), + context.get("body_phrase"), + context.get("skin"), + context.get("hair"), + context.get("eyes"), + ] + return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + + +def _insta_of_cast_descriptors( + primary_descriptor: str, + seed_config: dict[str, int], + seed: int, + row_number: int, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + women_count: int, + men_count: int, +) -> list[str]: + descriptors = [f"Woman A / primary creator: {primary_descriptor}"] + rng = _axis_rng(seed_config, "person", seed, row_number + 997) + for index in range(max(0, women_count - 1)): + label = chr(ord("B") + index) + context = _appearance_for_subject(rng, "woman", ethnicity, figure, no_plus_women, no_black) + descriptors.append(f"Woman {label}: {_insta_of_descriptor_from_context(context)}") + for index in range(max(0, men_count)): + label = chr(ord("A") + index) + context = _appearance_for_subject(rng, "man", ethnicity, figure, no_plus_women, no_black) + descriptors.append(f"Man {label}: {_insta_of_descriptor_from_context(context)}") + return descriptors + + def _insta_of_cast_phrase(women_count: int, men_count: int) -> str: context = _configured_cast_context(women_count, men_count) return context["cast_summary"] @@ -2097,6 +2149,24 @@ def build_insta_of_pair( ) descriptor = _insta_of_descriptor(soft_row) + cast_descriptors = _insta_of_cast_descriptors( + descriptor, + parsed_seed_config, + seed, + row_number, + ethnicity, + figure, + no_plus_women, + no_black, + hard_women_count, + hard_men_count, + ) + cast_descriptor_text = "; ".join(cast_descriptors) + soft_cast_descriptor_text = ( + cast_descriptor_text + if options["softcore_cast"] == "same_as_hardcore" + else f"Woman A / primary creator: {descriptor}" + ) platform_style = INSTA_OF_PLATFORM_STYLES[options["platform_style"]] soft_level = INSTA_OF_SOFT_LEVELS[options["softcore_level"]] hard_level = INSTA_OF_HARDCORE_LEVELS[options["hardcore_level"]] @@ -2121,6 +2191,7 @@ def build_insta_of_pair( soft_prompt = ( f"Insta/OF softcore mode: {platform_style}. Shared primary creator descriptor: {descriptor}. " f"Softcore setup: {soft_level}. Cast continuity: {soft_cast}. " + f"Shared cast descriptors: {soft_cast_descriptor_text}. " 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"{soft_camera_sentence}" @@ -2130,6 +2201,7 @@ def build_insta_of_pair( hard_prompt = ( f"Insta/OF hardcore mode: {platform_style}. Shared primary creator descriptor: {descriptor}. " f"Hardcore setup: {hard_level}. Cast: {hard_cast}. " + f"Shared cast descriptors: {cast_descriptor_text}. " "Apply the shared descriptor to the most visually central woman, keeping her continuous with the softcore version. " f"Role graph: {hard_row['role_graph']} Sexual scene: {hard_row['item']}. " f"Setting: {hard_scene}. Facial expressions: {hard_row['expression']}. Composition: {hard_composition}. " @@ -2159,6 +2231,7 @@ def build_insta_of_pair( "mode": "Insta/OF", "options": options, "shared_descriptor": descriptor, + "shared_cast_descriptors": cast_descriptors, "softcore_prompt": soft_prompt, "hardcore_prompt": hard_prompt, "softcore_negative_prompt": soft_negative,