From cb35e1881f27b77938bc48d364edb6526a2a5df3 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 14:32:54 +0200 Subject: [PATCH] Add prompt control and filter options --- README.md | 68 +++++--- __init__.py | 119 +++++++++++--- generate_prompt_batches.py | 125 +++++++++++++-- krea_formatter.py | 70 ++++++--- prompt_builder.py | 309 +++++++++++++++++++++++++++++++++---- 5 files changed, 592 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 27feedc..2ce0434 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The node is registered as: - `prompt_builder / SxCP Prompt Builder` - `prompt_builder / SxCP Seed Control` +- `prompt_builder / SxCP Seed Locker` - `prompt_builder / SxCP Camera Control` - `prompt_builder / SxCP Category Preset` - `prompt_builder / SxCP Cast Control` @@ -41,8 +42,8 @@ node. For cleaner workflows, use the split nodes: - `SxCP Generation Profile` outputs `generation_profile` for common behavior presets such as casual-clean, evocative-softcore, hardcore-intense, Krea2-friendly, or Flux-original. -- `SxCP Advanced Filters` outputs `filter_config` for ethnicity, figure, and - exclusion filters. +- `SxCP Advanced Filters` outputs `filter_config` for appearance include + checkboxes, figure, and plus-size inclusion. - `SxCP Prompt Builder From Configs` consumes those config outputs and produces the same prompt, negative, caption, metadata, category, and subcategory outputs as the full builder. @@ -50,7 +51,7 @@ node. For cleaner workflows, use the split nodes: The practical compact workflow is: `Category Preset` + `Cast Control` + `Generation Profile` + optional -`Advanced Filters`, `Seed Control`, `Camera Control`, and `Character Profile` +`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control`, and `Character Profile` into `Prompt Builder From Configs`. An importable default workflow is included at @@ -99,13 +100,18 @@ appearance remains stable. `SxCP Seed Control` outputs `seed_config`, which can be connected to the prompt builder's optional `seed_config` input. +`SxCP Seed Locker` is the fast version for iteration. Set `base_seed` to a seed +you like, choose one `reroll_axis`, and connect its `seed_config`. All other +axes stay frozen to `base_seed`; the rerolled axis follows `reroll_seed`, or the +main prompt seed when `reroll_seed=-1`. + `SxCP Camera Control` outputs `camera_config`, which can be connected to the prompt builder or the Insta/OF pair node. It makes camera/framing first-class instead of relying on a weak phrase inside the prompt. Camera controls: -- `camera_mode`: `standard`, `handheld_selfie`, `mirror_selfie`, +- `camera_mode`: `disabled`, `standard`, `handheld_selfie`, `mirror_selfie`, `phone_tripod`, `creator_pov`, `bed_selfie`, `bathroom_mirror`, `phone_flash`, or `action_cam`. - `shot_size`: `auto`, `full_body`, `three_quarter`, `waist_up`, `close_up`, @@ -119,6 +125,8 @@ Camera controls: - `phone_visibility`: `auto`, `phone_visible`, `phone_hidden`, `screen_reflection`, or `ring_light_visible`. - `priority`: `soft_hint`, `strong`, or `locked`. +- `camera_detail`: `off` emits no camera sentence, `compact` emits one short + camera sentence, and `full` emits the full detailed camera constraint. `SxCP Caption Naturalizer` rewrites tag-like captions or labeled prompts into more natural language. Connect the prompt builder's `metadata_json` output to @@ -177,18 +185,17 @@ It outputs: - `method` `SxCP Insta/OF Prompt Pair` is a special paired-output mode. It creates one -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 +primary creator descriptor internally, then returns both a softcore prompt and a +hardcore prompt from that descriptor. This is useful when you want matching 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 +cast descriptors such as `Woman A` 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. In that setup, the softcore -prompt keeps the same listed adult cast physically present together in a -non-explicit teaser pose, with deterministic non-explicit partner outfits and a -shared cast pose. +`continuity=same_creator_same_room` when you want both outputs to reuse the cast +and location. The generated positive prompts are still standalone: each output +lists the relevant cast descriptors directly and does not depend on the image +model carrying context from another prompt. It outputs: @@ -217,10 +224,11 @@ Options: - `hardcore_women_count` and `hardcore_men_count`: used when `hardcore_cast` is `use_counts`. The pair mode always keeps at least one adult woman as the primary creator so the shared descriptor remains valid. -- `softcore_level`: `social_tease`, `lingerie_tease`, `implied_nude`, or - `explicit_tease`. Insta/OF softcore uses dedicated outfit pools so teaser - prompts do not randomly pull hardcore-adjacent harness, nude accessory, - microwear, or shirtless partner styling. +- `softcore_level`: `social_tease`, `lingerie_tease`, `implied_nude`, + `explicit_tease`, or `explicit_nude`. Insta/OF softcore uses dedicated outfit + pools so teaser prompts do not randomly pull hardcore-adjacent harness, + microwear, or shirtless partner styling. `explicit_nude` is available when + you want visible nude creator-shot framing without a sex act. - `hardcore_level`: `explicit` or `hardcore`. - `softcore_expression_intensity`: `0.0` is mild/controlled, `0.5` is sensual, `1.0` strongly favors more heated softcore faces. @@ -228,12 +236,17 @@ Options: hardcore, `1.0` strongly favors ahegao-style, drooling, fucked-out, climax, and messy orgasm expressions. - `platform_style`: `hybrid`, `instagram`, or `onlyfans`. -- `continuity`: `same_creator_same_room` keeps the scene/composition aligned; - `same_creator_new_scene` keeps the same creator descriptor but lets the - hardcore scene use its own setting. +- `continuity`: `same_creator_same_room` keeps the scene aligned while each + output keeps its own pose/composition; `same_creator_new_scene` keeps the same + creator descriptor but lets the hardcore scene use its own setting. +- `hardcore_clothing_continuity`: `none`, `same_outfit`, `partially_removed`, + `implied_nude`, or `explicit_nude`. This controls whether the hardcore prompt + references the softcore outfit, uses it displaced/removed, or makes Woman A + explicitly nude. - `softcore_camera_mode`: base camera mode for the softcore output. - `hardcore_camera_mode`: `same_as_softcore` or a separate base camera mode for the hardcore output. +- `camera_detail`: `off`, `compact`, or `full` for the pair prompt camera text. ## Built-In Categories @@ -242,7 +255,10 @@ The node keeps the original generator controls: - `category`: `auto_weighted`, `woman`, `man`, `couple`, `group_or_layout`, or a custom JSON category. - `clothing`: `full` or `minimal`. - `minimal_clothing_ratio`: `-1` disables mixing; `0.0` to `1.0` mixes minimal/full clothing. -- `ethnicity`: `any`, `asian`, `white_asian`. +- `ethnicity`: `any`, `european`, `mediterranean_mena`, `latina`, + `east_asian`, `southeast_asian`, `south_asian`, `black_african`, + `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_intensity`: `0.0` favors mild, neutral, controlled expressions; `0.5` favors balanced category expressions; `1.0` strongly favors the most @@ -252,8 +268,8 @@ The node keeps the original generator controls: - `standard_pose_ratio`: `-1` disables mixing; `0.0` to `1.0` mixes standard/evocative poses. - `backside_bias`: `0.0` to `1.0`, applies to evocative single-subject poses. - `figure`: `curvy`, `balanced`, `bombshell`. -- `no_plus_women`: excludes plus-size women. -- `no_black`: excludes Black/African-coded women from women-focused pools. +- In split workflows, use `SxCP Advanced Filters` checkboxes instead of negative + toggles. Black/African and plus-size are positive include choices there. - Optional `camera_config`: connect `SxCP Camera Control` to force selfie, phone, lens, angle, distance, crop, and camera-priority behavior. This applies to custom categories too, including `Hardcore sexual poses`. @@ -475,6 +491,14 @@ alone. The main `seed` input is still the default master seed. Connect `SxCP Seed Control` to `seed_config` when you want to lock or vary specific axes. +For normal prompt iteration, `SxCP Seed Locker` is usually simpler: + +- `base_seed`: the seed whose character/location/etc. you want to keep. +- `reroll_axis`: `none`, `content`, `person`, `scene`, `pose`, `role`, + `expression`, `composition`, `content_pose`, or `scene_pose`. +- `reroll_seed`: `-1` makes the selected axis follow the main prompt seed; + `0` or higher pins that selected axis to a specific seed. + Seed values: - `-1`: follow the main seed. diff --git a/__init__.py b/__init__.py index 2b0726d..bbf8ebc 100644 --- a/__init__.py +++ b/__init__.py @@ -15,7 +15,9 @@ try: build_prompt, build_prompt_from_configs, build_seed_config_json, + build_seed_lock_config_json, camera_angle_choices, + camera_detail_choices, camera_distance_choices, camera_lens_choices, camera_mode_choices, @@ -27,6 +29,7 @@ try: category_preset_choices, category_choices, character_profile_choices, + ethnicity_choices, generation_profile_choices, load_character_profile_json, subcategory_choices, @@ -46,7 +49,9 @@ except ImportError: build_prompt, build_prompt_from_configs, build_seed_config_json, + build_seed_lock_config_json, camera_angle_choices, + camera_detail_choices, camera_distance_choices, camera_lens_choices, camera_mode_choices, @@ -58,6 +63,7 @@ except ImportError: category_preset_choices, category_choices, character_profile_choices, + ethnicity_choices, generation_profile_choices, load_character_profile_json, subcategory_choices, @@ -77,13 +83,11 @@ class SxCPPromptBuilder: "start_index": ("INT", {"default": 41, "min": 1, "max": 1000000, "step": 1}), "seed": ("INT", {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1}), "clothing": (["full", "minimal"], {"default": "full"}), - "ethnicity": (["any", "asian", "white_asian"], {"default": "any"}), + "ethnicity": (ethnicity_choices(), {"default": "any"}), "poses": (["standard", "evocative"], {"default": "standard"}), "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"}), - "no_plus_women": ("BOOLEAN", {"default": False}), - "no_black": ("BOOLEAN", {"default": False}), "women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), "men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), "minimal_clothing_ratio": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), @@ -118,8 +122,6 @@ class SxCPPromptBuilder: expression_intensity, backside_bias, figure, - no_plus_women, - no_black, women_count, men_count, minimal_clothing_ratio, @@ -131,6 +133,8 @@ class SxCPPromptBuilder: character_profile="", extra_positive="", extra_negative="", + no_plus_women=False, + no_black=False, ): row = build_prompt( category=category, @@ -218,6 +222,46 @@ class SxCPSeedControl: ) +class SxCPSeedLocker: + @classmethod + def INPUT_TYPES(cls): + seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1} + reroll_seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1} + return { + "required": { + "base_seed": ("INT", seed_spec), + "reroll_axis": ( + [ + "none", + "category", + "subcategory", + "content", + "person", + "scene", + "pose", + "role", + "expression", + "composition", + "content_pose", + "scene_pose", + ], + {"default": "none"}, + ), + "reroll_seed": ("INT", reroll_seed_spec), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("seed_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, base_seed, reroll_axis, reroll_seed): + config = build_seed_lock_config_json(base_seed=base_seed, reroll_axis=reroll_axis, reroll_seed=reroll_seed) + summary = f"base {base_seed}; reroll {reroll_axis} with {'main seed' if int(reroll_seed) < 0 else reroll_seed}" + return config, summary + + class SxCPCameraControl: @classmethod def INPUT_TYPES(cls): @@ -231,6 +275,7 @@ class SxCPCameraControl: "orientation": (camera_orientation_choices(), {"default": "vertical_story"}), "phone_visibility": (camera_phone_choices(), {"default": "phone_visible"}), "priority": (camera_priority_choices(), {"default": "locked"}), + "camera_detail": (camera_detail_choices(), {"default": "compact"}), } } @@ -249,6 +294,7 @@ class SxCPCameraControl: orientation, phone_visibility, priority, + camera_detail, ): return ( build_camera_config_json( @@ -260,6 +306,7 @@ class SxCPCameraControl: orientation=orientation, phone_visibility=phone_visibility, priority=priority, + camera_detail=camera_detail, ), ) @@ -360,10 +407,17 @@ class SxCPAdvancedFilters: def INPUT_TYPES(cls): return { "required": { - "ethnicity": (["any", "asian", "white_asian"], {"default": "any"}), + "include_european": ("BOOLEAN", {"default": True}), + "include_mediterranean_mena": ("BOOLEAN", {"default": True}), + "include_latina": ("BOOLEAN", {"default": True}), + "include_east_asian": ("BOOLEAN", {"default": True}), + "include_southeast_asian": ("BOOLEAN", {"default": True}), + "include_south_asian": ("BOOLEAN", {"default": True}), + "include_black_african": ("BOOLEAN", {"default": True}), + "include_indigenous": ("BOOLEAN", {"default": True}), + "include_mixed": ("BOOLEAN", {"default": True}), + "include_plus_size": ("BOOLEAN", {"default": True}), "figure": (["curvy", "balanced", "bombshell"], {"default": "curvy"}), - "no_plus_women": ("BOOLEAN", {"default": False}), - "no_black": ("BOOLEAN", {"default": False}), } } @@ -372,13 +426,33 @@ class SxCPAdvancedFilters: FUNCTION = "build" CATEGORY = "prompt_builder" - def build(self, ethnicity, figure, no_plus_women, no_black): + def build( + self, + include_european, + include_mediterranean_mena, + include_latina, + include_east_asian, + include_southeast_asian, + include_south_asian, + include_black_african, + include_indigenous, + include_mixed, + include_plus_size, + figure, + ): return ( build_filter_config_json( - ethnicity=ethnicity, figure=figure, - no_plus_women=no_plus_women, - no_black=no_black, + include_european=include_european, + include_mediterranean_mena=include_mediterranean_mena, + include_latina=include_latina, + include_east_asian=include_east_asian, + include_southeast_asian=include_southeast_asian, + include_south_asian=include_south_asian, + include_black_african=include_black_african, + include_indigenous=include_indigenous, + include_mixed=include_mixed, + include_plus_size=include_plus_size, ), ) @@ -673,14 +747,16 @@ class SxCPInstaOFOptions: "hardcore_cast": (["use_counts", "couple", "threesome", "group"], {"default": "use_counts"}), "hardcore_women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), "hardcore_men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), - "softcore_level": (["social_tease", "lingerie_tease", "implied_nude", "explicit_tease"], {"default": "lingerie_tease"}), + "softcore_level": (["social_tease", "lingerie_tease", "implied_nude", "explicit_tease", "explicit_nude"], {"default": "lingerie_tease"}), "hardcore_level": (["explicit", "hardcore"], {"default": "hardcore"}), "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"}), "continuity": (["same_creator_same_room", "same_creator_new_scene"], {"default": "same_creator_same_room"}), + "hardcore_clothing_continuity": (["none", "same_outfit", "partially_removed", "implied_nude", "explicit_nude"], {"default": "partially_removed"}), "softcore_camera_mode": (camera_mode_choices(), {"default": "handheld_selfie"}), "hardcore_camera_mode": (["same_as_softcore"] + camera_mode_choices(), {"default": "same_as_softcore"}), + "camera_detail": (camera_detail_choices(), {"default": "compact"}), } } @@ -701,8 +777,10 @@ class SxCPInstaOFOptions: hardcore_expression_intensity, platform_style, continuity, + hardcore_clothing_continuity, softcore_camera_mode, hardcore_camera_mode, + camera_detail, ): return ( build_insta_of_options_json( @@ -716,8 +794,10 @@ class SxCPInstaOFOptions: hardcore_expression_intensity=hardcore_expression_intensity, platform_style=platform_style, continuity=continuity, + hardcore_clothing_continuity=hardcore_clothing_continuity, softcore_camera_mode=softcore_camera_mode, hardcore_camera_mode=hardcore_camera_mode, + camera_detail=camera_detail, ), ) @@ -730,16 +810,15 @@ class SxCPInstaOFPromptPair: "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), "start_index": ("INT", {"default": 41, "min": 1, "max": 1000000, "step": 1}), "seed": ("INT", {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1}), - "ethnicity": (["any", "asian", "white_asian"], {"default": "any"}), + "ethnicity": (ethnicity_choices(), {"default": "any"}), "figure": (["curvy", "balanced", "bombshell"], {"default": "curvy"}), - "no_plus_women": ("BOOLEAN", {"default": False}), - "no_black": ("BOOLEAN", {"default": False}), "trigger": ("STRING", {"default": "sxcpinup_coloredpencil"}), "prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}), }, "optional": { "seed_config": ("STRING", {"default": "", "multiline": True}), "options_json": ("STRING", {"default": "", "multiline": True}), + "filter_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}), @@ -768,16 +847,17 @@ class SxCPInstaOFPromptPair: seed, ethnicity, figure, - no_plus_women, - no_black, trigger, prepend_trigger_to_prompt, seed_config="", options_json="", + filter_config="", camera_config="", character_profile="", extra_positive="", extra_negative="", + no_plus_women=False, + no_black=False, ): row = build_insta_of_pair( row_number=row_number, @@ -791,6 +871,7 @@ class SxCPInstaOFPromptPair: prepend_trigger_to_prompt=prepend_trigger_to_prompt, seed_config=seed_config or "", options_json=options_json or "", + filter_config=filter_config or "", camera_config=camera_config or "", character_profile=character_profile or "", extra_positive=extra_positive or "", @@ -811,6 +892,7 @@ class SxCPInstaOFPromptPair: NODE_CLASS_MAPPINGS = { "SxCPPromptBuilder": SxCPPromptBuilder, "SxCPSeedControl": SxCPSeedControl, + "SxCPSeedLocker": SxCPSeedLocker, "SxCPCameraControl": SxCPCameraControl, "SxCPCategoryPreset": SxCPCategoryPreset, "SxCPCastControl": SxCPCastControl, @@ -828,6 +910,7 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { "SxCPPromptBuilder": "SxCP Prompt Builder", "SxCPSeedControl": "SxCP Seed Control", + "SxCPSeedLocker": "SxCP Seed Locker", "SxCPCameraControl": "SxCP Camera Control", "SxCPCategoryPreset": "SxCP Category Preset", "SxCPCastControl": "SxCP Cast Control", diff --git a/generate_prompt_batches.py b/generate_prompt_batches.py index fe94f81..a1f88ba 100755 --- a/generate_prompt_batches.py +++ b/generate_prompt_batches.py @@ -2782,21 +2782,122 @@ WHITE_KEYWORDS = ( "french", "mediterranean", ) +EAST_ASIAN_KEYWORDS = ( + "east asian", + "japanese", + "korean", + "chinese", + "taiwanese", + "mongolian", + "tibetan", + "manchu", + "okinawan", +) +SOUTHEAST_ASIAN_KEYWORDS = ( + "southeast asian", + "vietnamese", + "thai", + "filipina", + "filipino", + "indonesian", + "malay", + "cambodian", + "lao", + "burmese", + "singaporean", + "hmong", + "balinese", +) +SOUTH_ASIAN_KEYWORDS = ( + "south asian", + "indian", + "punjabi", + "tamil", + "bengali", + "sri lankan", + "nepali", + "pakistani", + "gujarati", + "bangladeshi", + "malayali", + "kashmiri", +) +MEDITERRANEAN_MENA_KEYWORDS = ( + "mediterranean", + "greek", + "italian", + "spanish", + "portuguese", + "turkish", + "persian", + "levantine", + "maghrebi", + "egyptian", + "moroccan", + "amazigh", + "kurdish", + "middle-eastern", + "middle eastern", + "mena", + "olive", +) +LATINA_KEYWORDS = ( + "latina", + "latino", + "mexican", + "chicana", + "colombian", + "brazilian", + "puerto rican", + "cuban", + "dominican", + "venezuelan", + "peruvian", + "chilean", + "argentine", + "uruguayan", + "ecuadorian", +) +BLACK_AFRICAN_KEYWORDS = ("african", "african-diaspora", "cape verdean") +INDIGENOUS_KEYWORDS = ("indigenous", "amazigh") +MIXED_KEYWORDS = ("mixed",) +ETHNICITY_KEYWORD_GROUPS = { + "asian": ASIAN_KEYWORDS, + "white_asian": WHITE_KEYWORDS + ASIAN_KEYWORDS, + "european": WHITE_KEYWORDS, + "mediterranean_mena": MEDITERRANEAN_MENA_KEYWORDS, + "latina": LATINA_KEYWORDS, + "east_asian": EAST_ASIAN_KEYWORDS, + "southeast_asian": SOUTHEAST_ASIAN_KEYWORDS, + "south_asian": SOUTH_ASIAN_KEYWORDS, + "black_african": BLACK_AFRICAN_KEYWORDS, + "indigenous": INDIGENOUS_KEYWORDS, + "mixed": MIXED_KEYWORDS, +} def by_ethnicity(pool: list, ethnicity: str) -> list: """Filter an appearance pool by heritage keywords found in the skin field. 'asian' = East/Southeast/South/Central Asian; 'white_asian' = white/European + Asian; 'any' returns the full pool.""" + ethnicity = str(ethnicity or "any").strip().lower() if ethnicity == "any": return pool - if ethnicity == "asian": - kws = ASIAN_KEYWORDS - elif ethnicity == "white_asian": - kws = WHITE_KEYWORDS + ASIAN_KEYWORDS - else: - kws = (ethnicity,) + tokens = [token.strip() for token in re.split(r"[,+|;/\s]+", ethnicity) if token.strip()] + kws: list[str] = [] + exclude_kws: list[str] = [] + for token in tokens: + if token.startswith("exclude_"): + exclude_key = token.removeprefix("exclude_") + exclude_kws.extend(ETHNICITY_KEYWORD_GROUPS.get(exclude_key, (exclude_key,))) + continue + if token in ("not_mixed", "no_mixed"): + exclude_kws.extend(MIXED_KEYWORDS) + continue + kws.extend(ETHNICITY_KEYWORD_GROUPS.get(token, (token,))) filtered = [e for e in pool if any(k in e[3].lower() for k in kws)] + if exclude_kws: + filtered = [e for e in filtered if not any(k in e[3].lower() for k in exclude_kws)] return filtered or pool @@ -2894,10 +2995,7 @@ def make_single(index: int, batch: int, rng: random.Random, gender: str, expr_de clothes = choose(rng, WOMEN_CLOTHES_MINIMAL if minimal else WOMEN_CLOTHES) figure_note = choose(rng, figure_pool(figure)) else: - # The ethnicity bias targets women; men stay any-heritage unless the - # batch is a fully-themed 'asian' batch. - men_eth = ethnicity if ethnicity == "asian" else "any" - men_pool = by_ethnicity(MEN, men_eth) + men_pool = by_ethnicity(MEN, ethnicity) subject, age, body, skin, hair, eyes = choose(rng, men_pool) clothes = choose(rng, MEN_CLOTHES_MINIMAL if minimal else MEN_CLOTHES) figure_note = "" @@ -3247,11 +3345,10 @@ def main() -> None: ) parser.add_argument( "--ethnicity", - choices=["any", "asian", "white_asian"], + choices=["any", *ETHNICITY_KEYWORD_GROUPS.keys()], default="any", - help="'any' = balanced heritage mix (default); " - "'asian' = restrict all subjects to Asian (East/Southeast/South/Central Asian); " - "'white_asian' = bias women to white/European + Asian (men stay any heritage).", + help="'any' = balanced heritage mix (default). Other values restrict the appearance pool " + "by heritage keywords, e.g. east_asian, latina, black_african, mixed, asian, or white_asian.", ) parser.add_argument( "--poses", diff --git a/krea_formatter.py b/krea_formatter.py index 94f9f10..029f34b 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -164,6 +164,10 @@ def _combine_negative(*parts: str) -> str: return ", ".join(cleaned) +def _prompt_cast_descriptors(text: str) -> str: + return _clean(text).replace("Woman A / primary creator:", "Woman A:") + + def _clean_age(age: Any) -> str: return _clean(age) @@ -205,15 +209,50 @@ def _camera_phrase(row: dict[str, Any]) -> str: return directive config = row.get("camera_config") if isinstance(config, dict): + detail = _clean(config.get("camera_detail")) + if detail == "off" or _clean(config.get("camera_mode")) == "disabled": + return "" mode = _clean(config.get("camera_mode")).replace("_", " ") shot = _clean(config.get("shot_size")).replace("_", " ") angle = _clean(config.get("angle")).replace("_", " ") pieces = [piece for piece in (mode, shot, angle) if piece and piece != "auto" and piece != "standard"] if pieces: - return "Camera framing uses " + ", ".join(pieces) + return "Camera: " + ", ".join(pieces) return "" +def _camera_phrase_from_config(config: Any) -> str: + if not isinstance(config, dict): + return "" + detail = _clean(config.get("camera_detail")) + if detail == "off" or _clean(config.get("camera_mode")) == "disabled": + return "" + values = [ + _clean(config.get("camera_mode")).replace("_", " "), + _clean(config.get("shot_size")).replace("_", " "), + _clean(config.get("angle")).replace("_", " "), + _clean(config.get("lens")).replace("_", " "), + _clean(config.get("distance")).replace("_", " "), + _clean(config.get("orientation")).replace("_", " "), + _clean(config.get("phone_visibility")).replace("_", " "), + ] + pieces = [value for value in values if value and value not in ("auto", "standard")] + if not pieces: + return "" + return "Camera: " + ", ".join(pieces) + + +def _pair_camera_phrase(directive: Any, config: Any, row: dict[str, Any]) -> str: + directive_text = _clean(directive) + if directive_text: + return directive_text + if isinstance(config, dict) and ( + _clean(config.get("camera_detail")) == "off" or _clean(config.get("camera_mode")) == "disabled" + ): + return "" + return _camera_phrase_from_config(config) or _camera_phrase(row) + + def _style_phrase(row: dict[str, Any], style_mode: str) -> str: if style_mode == "minimal": return "" @@ -323,31 +362,27 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) cast_descriptor_text = "; ".join(_clean(item) for item in cast_descriptors if _clean(item)) else: cast_descriptor_text = _clean(cast_descriptors) + cast_descriptor_text = _prompt_cast_descriptors(cast_descriptor_text) 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) - hard_camera = _clean(row.get("hardcore_camera_directive")) or _camera_phrase(hard) + soft_camera = _pair_camera_phrase(row.get("softcore_camera_directive"), row.get("softcore_camera_config"), soft) + hard_camera = _pair_camera_phrase(row.get("hardcore_camera_directive"), row.get("hardcore_camera_config"), hard) soft_style = _style_phrase(soft, style_mode) hard_style = _style_phrase(hard, style_mode) options = row.get("options") if isinstance(row.get("options"), dict) else {} soft_level = _clean(options.get("softcore_level")).replace("_", " ") hard_level = _clean(options.get("hardcore_level")).replace("_", " ") - hard_cast = _clean(row.get("hardcore_women_count")) - hard_men = _clean(row.get("hardcore_men_count")) - 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") + hard_composition = 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}" + else f"Woman A: {descriptor}" ) same_soft_cast = options.get("softcore_cast") == "same_as_hardcore" soft_cast_presence = ( - "The same cast is present together in a non-explicit teaser pose, with no sex act or genital contact" + "Woman A and the listed partners are present together in a non-explicit teaser pose, with no sex act or genital contact" if same_soft_cast else "The softcore version focuses on Woman A alone" ) @@ -361,12 +396,11 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) partner_pose = "" soft_parts = [ - descriptor, - f"Shared cast descriptors: {soft_cast_descriptor_text}" if same_soft_cast and soft_cast_descriptor_text else "", - f"Softcore primary creator descriptor: {soft_cast_descriptor_text}" if not same_soft_cast and soft_cast_descriptor_text else "", + f"Cast descriptors: {soft_cast_descriptor_text}" if same_soft_cast and soft_cast_descriptor_text else "", + soft_cast_descriptor_text if not same_soft_cast and soft_cast_descriptor_text else "", soft_cast_presence, f"Partner softcore styling: {partner_outfit_text}" if partner_outfit_text else "", - f"The shared softcore cast pose is {partner_pose}" if partner_pose else "", + f"Cast pose: {partner_pose}" if partner_pose 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 "", @@ -377,9 +411,9 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) soft_style if detail_level != "concise" else "", ] hard_parts = [ - f"The primary creator remains {descriptor}, visually central in a {hard_level or 'hardcore'} scene", - f"{'Shared' if same_soft_cast else 'Hardcore'} cast descriptors: {cast_descriptor_text}" if cast_descriptor_text else "", - f"The cast includes {hard_cast_text}" if hard_cast_text else "", + f"{hard_level or 'hardcore'} scene with Woman A visually central", + f"Cast descriptors: {cast_descriptor_text}" if cast_descriptor_text else "", + _clean(row.get("hardcore_clothing_state")), _clean(hard.get("role_graph")), f"The sexual action is {hard.get('item')}" if hard.get("item") else "", f"set in {hard_scene}" if hard_scene else "", diff --git a/prompt_builder.py b/prompt_builder.py index 335f308..720ef49 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -49,6 +49,35 @@ SEED_AXIS_ALIASES = { "composition": ("composition_seed", "camera_seed", "composition"), } +SEED_LOCK_AXES = ( + "category", + "subcategory", + "content", + "person", + "scene", + "pose", + "role", + "expression", + "composition", +) + +ETHNICITY_FILTER_CHOICES = [ + "any", + "european", + "mediterranean_mena", + "latina", + "east_asian", + "southeast_asian", + "south_asian", + "black_african", + "indigenous", + "mixed", + "asian", + "white_asian", +] + +CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] + GENERIC_POSITIVE_SUFFIX = ( "Use crisp clean comic linework, detailed hatching, soft blended shading, " "pastel skin tones, muted blues and pinks, warm sensual lighting, and tactile textured paper." @@ -79,6 +108,7 @@ LAYOUT_TEMPLATE = ( ) CAMERA_MODE_PROMPTS = { + "disabled": "", "standard": "", "handheld_selfie": ( "Camera mode: handheld smartphone selfie, close arm-length framing, visible creator-shot perspective, " @@ -109,6 +139,47 @@ CAMERA_MODE_PROMPTS = { ), } +CAMERA_COMPACT_LABELS = { + "disabled": "", + "standard": "", + "handheld_selfie": "handheld smartphone selfie", + "mirror_selfie": "mirror selfie", + "phone_tripod": "phone tripod / ring-light setup", + "creator_pov": "creator-held POV", + "bed_selfie": "bed selfie", + "bathroom_mirror": "bathroom mirror selfie", + "phone_flash": "phone-flash selfie", + "action_cam": "handheld action-camera view", + "full_body": "full body", + "three_quarter": "three-quarter body", + "waist_up": "waist-up", + "close_up": "close-up", + "extreme_close_up": "extreme close-up", + "eye_level": "eye-level", + "high_angle": "high-angle", + "low_angle": "low-angle", + "overhead": "overhead", + "side_profile": "side-profile", + "rear_view": "rear-view", + "mirror_reflection": "mirror reflection", + "smartphone_wide": "smartphone wide-angle", + "ultra_wide": "ultra-wide", + "portrait_lens": "phone portrait lens", + "telephoto": "telephoto-style", + "macro_detail": "macro detail", + "arm_length": "arm-length", + "near_body": "near-body", + "bedside": "bedside phone", + "room_corner": "room-corner phone", + "vertical_story": "vertical 9:16", + "square_feed": "square feed", + "horizontal": "horizontal", + "phone_visible": "phone visible", + "phone_hidden": "phone hidden", + "screen_reflection": "screen reflection", + "ring_light_visible": "ring light visible", +} + CAMERA_SHOT_PROMPTS = { "auto": "", "full_body": "Shot size: full body visible, head-to-toe framing, no important body parts cropped out.", @@ -917,13 +988,46 @@ def build_filter_config_json( figure: str = "curvy", no_plus_women: bool = False, no_black: bool = False, + include_european: bool = True, + include_mediterranean_mena: bool = True, + include_latina: bool = True, + include_east_asian: bool = True, + include_southeast_asian: bool = True, + include_south_asian: bool = True, + include_black_african: bool = True, + include_indigenous: bool = True, + include_mixed: bool = True, + include_plus_size: bool = True, ) -> str: + include_flags = { + "european": include_european, + "mediterranean_mena": include_mediterranean_mena, + "latina": include_latina, + "east_asian": include_east_asian, + "southeast_asian": include_southeast_asian, + "south_asian": include_south_asian, + "black_african": include_black_african, + "indigenous": include_indigenous, + "mixed": include_mixed, + } + selected_ethnicities = [key for key, enabled in include_flags.items() if enabled] + disabled_ethnicities = [key for key, enabled in include_flags.items() if not enabled] + enabled_ethnicities = list(selected_ethnicities) + if enabled_ethnicities: + enabled_ethnicities.extend(f"exclude_{key}" for key in disabled_ethnicities) + if 0 < len(selected_ethnicities) < len(include_flags): + ethnicity = "+".join(enabled_ethnicities) + elif ethnicity not in ETHNICITY_FILTER_CHOICES: + ethnicity = "any" return json.dumps( { - "ethnicity": ethnicity if ethnicity in ("any", "asian", "white_asian") else "any", + "ethnicity": ethnicity, + "ethnicity_includes": selected_ethnicities, "figure": figure if figure in ("curvy", "balanced", "bombshell") else "curvy", - "no_plus_women": bool(no_plus_women), - "no_black": bool(no_black), + "include_plus_size": bool(include_plus_size), + "include_black_african": bool(include_black_african), + "no_plus_women": not bool(include_plus_size) or bool(no_plus_women), + "no_black": not bool(include_black_african) or bool(no_black), }, ensure_ascii=True, sort_keys=True, @@ -931,7 +1035,14 @@ def build_filter_config_json( def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str, Any]: - defaults = {"ethnicity": "any", "figure": "curvy", "no_plus_women": False, "no_black": False} + defaults = { + "ethnicity": "any", + "figure": "curvy", + "no_plus_women": False, + "no_black": False, + "include_plus_size": True, + "include_black_african": True, + } if not filter_config: return defaults if isinstance(filter_config, dict): @@ -944,8 +1055,11 @@ def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str if not isinstance(raw, dict): raise ValueError("filter_config must be a JSON object") parsed = {**defaults, **raw} - parsed["ethnicity"] = parsed["ethnicity"] if parsed.get("ethnicity") in ("any", "asian", "white_asian") else "any" + ethnicity = str(parsed.get("ethnicity") or "any") + parsed["ethnicity"] = ethnicity if ethnicity == "any" or ethnicity in ETHNICITY_FILTER_CHOICES or "+" in ethnicity else "any" parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell") else "curvy" + parsed["include_plus_size"] = bool(parsed.get("include_plus_size")) + parsed["include_black_african"] = bool(parsed.get("include_black_african")) parsed["no_plus_women"] = bool(parsed.get("no_plus_women")) parsed["no_black"] = bool(parsed.get("no_black")) return parsed @@ -997,6 +1111,34 @@ def build_seed_config_json( ) +def build_seed_lock_config_json( + base_seed: int = 20260614, + reroll_axis: str = "none", + reroll_seed: int = -1, +) -> str: + base_seed = int(base_seed) + reroll_seed = int(reroll_seed) + reroll_groups = { + "none": (), + "category": ("category",), + "subcategory": ("subcategory",), + "content": ("content",), + "person": ("person",), + "scene": ("scene",), + "pose": ("pose", "role"), + "role": ("role",), + "expression": ("expression",), + "composition": ("composition",), + "content_pose": ("content", "pose", "role"), + "scene_pose": ("scene", "pose", "role"), + } + reroll = set(reroll_groups.get(str(reroll_axis or "none"), ())) + config: dict[str, int] = {} + for axis in SEED_LOCK_AXES: + config[f"{axis}_seed"] = reroll_seed if axis in reroll else base_seed + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + def _parse_seed_config(seed_config: str | dict[str, Any] | None) -> dict[str, int]: if not seed_config: return {} @@ -1075,6 +1217,14 @@ def camera_mode_choices() -> list[str]: return list(CAMERA_MODE_PROMPTS) +def ethnicity_choices() -> list[str]: + return list(ETHNICITY_FILTER_CHOICES) + + +def camera_detail_choices() -> list[str]: + return list(CAMERA_DETAIL_CHOICES) + + def camera_shot_choices() -> list[str]: return list(CAMERA_SHOT_PROMPTS) @@ -1112,6 +1262,7 @@ def build_camera_config_json( orientation: str = "auto", phone_visibility: str = "auto", priority: str = "strong", + camera_detail: str = "compact", ) -> str: return json.dumps( { @@ -1123,6 +1274,7 @@ def build_camera_config_json( "orientation": orientation, "phone_visibility": phone_visibility, "priority": priority, + "camera_detail": camera_detail, }, ensure_ascii=True, sort_keys=True, @@ -1144,6 +1296,7 @@ def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str "orientation": "auto", "phone_visibility": "auto", "priority": "strong", + "camera_detail": "compact", } if not camera_config: return defaults @@ -1166,6 +1319,9 @@ def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str "orientation": _choice(parsed.get("orientation"), CAMERA_ORIENTATION_PROMPTS, defaults["orientation"]), "phone_visibility": _choice(parsed.get("phone_visibility"), CAMERA_PHONE_PROMPTS, defaults["phone_visibility"]), "priority": _choice(parsed.get("priority"), CAMERA_PRIORITY_PROMPTS, defaults["priority"]), + "camera_detail": str(parsed.get("camera_detail") or defaults["camera_detail"]) + if str(parsed.get("camera_detail") or defaults["camera_detail"]) in CAMERA_DETAIL_CHOICES + else defaults["camera_detail"], } @@ -1178,6 +1334,26 @@ def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_ def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, str]]: parsed = _parse_camera_config(camera_config) + if parsed["camera_detail"] == "off" or parsed["camera_mode"] == "disabled": + return "", parsed + if parsed["camera_detail"] == "compact": + values = [ + parsed["camera_mode"], + parsed["shot_size"], + parsed["angle"], + parsed["lens"], + parsed["distance"], + parsed["orientation"], + parsed["phone_visibility"], + ] + labels = [CAMERA_COMPACT_LABELS.get(value, value.replace("_", " ")) for value in values] + labels = [label for value, label in zip(values, labels) if label and value != "auto"] + if not labels: + return "", parsed + directive = "Camera: " + ", ".join(labels) + "." + if parsed["priority"] == "locked": + directive += " Keep this camera framing." + return directive, parsed parts = [ CAMERA_MODE_PROMPTS[parsed["camera_mode"]], CAMERA_SHOT_PROMPTS[parsed["shot_size"]], @@ -1679,7 +1855,7 @@ def _appearance_for_subject( subject_type = "woman" if rng.random() < 0.82 else "man" if subject_type == "man": - men_ethnicity = ethnicity if ethnicity == "asian" else "any" + men_ethnicity = ethnicity if ethnicity else "any" subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity)) return { "subject_type": "man", @@ -2452,7 +2628,7 @@ def build_prompt( start_index = max(1, int(start_index)) seed = int(seed) clothing = clothing if clothing in ("full", "minimal") else "full" - ethnicity = ethnicity if ethnicity in ("any", "asian", "white_asian") else "any" + 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" minimal_ratio = _ratio_or_none(minimal_clothing_ratio) @@ -2574,6 +2750,7 @@ INSTA_OF_SOFT_LEVELS = { "lingerie_tease": "premium OF teaser set, lingerie-focused, sensual and intimate", "implied_nude": "implied nude creator set, strategically covered body and intimate teaser framing", "explicit_tease": "stronger adult teaser set with bolder nude-adjacent styling and solo-tease framing", + "explicit_nude": "explicit nude creator set with fully nude solo-tease framing", } INSTA_OF_HARDCORE_LEVELS = { @@ -2587,6 +2764,14 @@ INSTA_OF_PLATFORM_STYLES = { "onlyfans": "OnlyFans-inspired creator shoot, intimate subscriber-view camera and candid premium-content framing", } +INSTA_OF_HARDCORE_CLOTHING_CONTINUITY = { + "none": "", + "same_outfit": "Woman A keeps the softcore outfit in the hardcore scene", + "partially_removed": "Woman A's softcore outfit is partially removed or pushed aside for the hardcore scene", + "implied_nude": "Woman A is nude-adjacent in the hardcore scene, with the softcore outfit slipping off or covering only part of the body", + "explicit_nude": "Woman A is fully nude in the hardcore scene, with the removed softcore outfit visible nearby", +} + INSTA_OF_NEGATIVE = ( "minors, childlike appearance, teen, underage, schoolgirl, non-consensual, coercion, rape, " "violence, injury, blood, gore, incest, bestiality, watermark, logo, readable username, social media UI" @@ -2603,6 +2788,7 @@ INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL = { "lingerie_tease": "Provocative erotic clothes / Provocative lingerie", "implied_nude": "Provocative erotic clothes / Provocative lingerie", "explicit_tease": "Provocative erotic clothes / Sheer exposed", + "explicit_nude": "Provocative erotic clothes / Nude accessories", } INSTA_OF_SOFTCORE_OUTFITS = { @@ -2642,6 +2828,14 @@ INSTA_OF_SOFTCORE_OUTFITS = { "bare-shoulder robe opened around covered lingerie, explicit adult tease without partnered contact", "strappy lingerie set with covered cups and high-waisted bottoms, styled as a stronger solo teaser", ], + "explicit_nude": [ + "fully nude creator styling with jewelry, heels, and direct adult selfie confidence", + "fully nude mirror-selfie styling with jewelry only and bold creator-shot framing", + "nude-on-sheets creator pose with lingerie discarded nearby and direct eye contact", + "fully nude vanity-mirror pose with heels, necklace, and premium adult teaser styling", + "nude shower-afterglow creator pose with wet hair, skin highlights, and phone-shot framing", + "fully nude bedroom creator pose with one hand holding the phone and lingerie visible nearby", + ], } INSTA_OF_SOFTCORE_POSES = { @@ -2677,6 +2871,14 @@ INSTA_OF_SOFTCORE_POSES = { "sitting at the vanity in a bolder covered lingerie pose with direct eye contact", "arching subtly in a solo adult tease while the styling keeps explicit anatomy obscured", ], + "explicit_nude": [ + "taking a bold nude mirror selfie with direct eye contact and the body clearly framed", + "posing fully nude on the bed with jewelry and heels as the only styling", + "standing at the vanity fully nude in a premium creator-shot pose", + "reclining fully nude on soft sheets with the phone held close", + "turning slightly in a nude mirror pose with the body framed head-to-thigh", + "kneeling fully nude in a controlled adult teaser pose with direct phone-camera awareness", + ], } INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS = [ @@ -2711,8 +2913,10 @@ def build_insta_of_options_json( hardcore_level: str = "hardcore", platform_style: str = "hybrid", continuity: str = "same_creator_same_room", + hardcore_clothing_continuity: str = "partially_removed", softcore_camera_mode: str = "handheld_selfie", hardcore_camera_mode: str = "same_as_softcore", + camera_detail: str = "compact", softcore_expression_intensity: float = 0.45, hardcore_expression_intensity: float = 0.85, ) -> str: @@ -2726,8 +2930,10 @@ def build_insta_of_options_json( "hardcore_level": hardcore_level, "platform_style": platform_style, "continuity": continuity, + "hardcore_clothing_continuity": hardcore_clothing_continuity, "softcore_camera_mode": softcore_camera_mode, "hardcore_camera_mode": hardcore_camera_mode, + "camera_detail": camera_detail, "softcore_expression_intensity": _clamped_float(softcore_expression_intensity, 0.45), "hardcore_expression_intensity": _clamped_float(hardcore_expression_intensity, 0.85), }, @@ -2746,8 +2952,10 @@ def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[s "hardcore_level": "hardcore", "platform_style": "hybrid", "continuity": "same_creator_same_room", + "hardcore_clothing_continuity": "partially_removed", "softcore_camera_mode": "handheld_selfie", "hardcore_camera_mode": "same_as_softcore", + "camera_detail": "compact", "softcore_expression_intensity": 0.45, "hardcore_expression_intensity": 0.85, } @@ -2769,9 +2977,15 @@ def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[s parsed["hardcore_level"] = parsed["hardcore_level"] if parsed["hardcore_level"] in INSTA_OF_HARDCORE_LEVELS else defaults["hardcore_level"] parsed["platform_style"] = parsed["platform_style"] if parsed["platform_style"] in INSTA_OF_PLATFORM_STYLES else defaults["platform_style"] parsed["continuity"] = parsed["continuity"] if parsed["continuity"] in ("same_creator_same_room", "same_creator_new_scene") else defaults["continuity"] + parsed["hardcore_clothing_continuity"] = ( + parsed["hardcore_clothing_continuity"] + if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY + else defaults["hardcore_clothing_continuity"] + ) parsed["softcore_camera_mode"] = parsed["softcore_camera_mode"] if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS else defaults["softcore_camera_mode"] if parsed["hardcore_camera_mode"] not in CAMERA_MODE_PROMPTS and parsed["hardcore_camera_mode"] != "same_as_softcore": 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_intensity"] = _clamped_float( parsed.get("softcore_expression_intensity"), defaults["softcore_expression_intensity"], @@ -2865,13 +3079,17 @@ def _insta_of_cast_phrase(women_count: int, men_count: int) -> str: return context["cast_summary"] +def _insta_of_prompt_cast_descriptors(text: str) -> str: + return str(text or "").replace("Woman A / primary creator:", "Woman A:") + + SOFTCORE_CAST_POSES = [ "standing together for a mirror selfie with bodies close but no sexual contact", "posing shoulder-to-shoulder in a creator-shot group teaser", "leaning together on the bed in a non-explicit subscriber preview", "sitting close together with hands kept above clothing", "arranged around Woman A in a flirtatious non-explicit teaser pose", - "posing in the same room as a coordinated adult creator set", + "posing together as a coordinated adult creator set", "standing near the phone tripod with relaxed teasing body language", "framed together in a softcore cast reveal with no sex act", ] @@ -2896,6 +3114,15 @@ def _insta_of_softcore_pose(rng: random.Random, level: str) -> str: return g.choose(rng, pool) +def _insta_of_hardcore_clothing_state(mode: str, softcore_outfit: str) -> str: + mode = mode if mode in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY else "none" + outfit = str(softcore_outfit or "").strip() + if mode == "none" or not outfit: + return "" + base = INSTA_OF_HARDCORE_CLOTHING_CONTINUITY[mode] + return f"Clothing state: {base}; softcore visual reference: {outfit}." + + def _insta_of_partner_styling( seed_config: dict[str, int], seed: int, @@ -2934,12 +3161,19 @@ def build_insta_of_pair( prepend_trigger_to_prompt: bool, seed_config: str | dict[str, Any] | None = None, options_json: str | dict[str, Any] | None = None, + filter_config: str | dict[str, Any] | None = None, camera_config: str | dict[str, Any] | None = None, character_profile: str | dict[str, Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: options = _parse_insta_of_options(options_json) + if filter_config: + filters = _parse_filter_config(filter_config) + ethnicity = filters["ethnicity"] + figure = filters["figure"] + no_plus_women = filters["no_plus_women"] + no_black = filters["no_black"] hard_women_count, hard_men_count = _insta_of_hardcore_counts(options) active_trigger = trigger.strip() or g.TRIGGER parsed_seed_config = _parse_seed_config(seed_config) @@ -3015,11 +3249,11 @@ def build_insta_of_pair( hard_women_count, hard_men_count, ) - cast_descriptor_text = "; ".join(cast_descriptors) + cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors)) soft_cast_descriptor_text = ( cast_descriptor_text if options["softcore_cast"] == "same_as_hardcore" - else f"Woman A / primary creator: {descriptor}" + else f"Woman A: {descriptor}" ) soft_partner_styling = _insta_of_partner_styling( parsed_seed_config, @@ -3039,33 +3273,44 @@ def build_insta_of_pair( hard_camera_mode = options["softcore_camera_mode"] soft_camera_config = _camera_config_with_mode(camera_config, options["softcore_camera_mode"]) hard_camera_config = _camera_config_with_mode(camera_config, hard_camera_mode) + soft_camera_config["camera_detail"] = options["camera_detail"] + hard_camera_config["camera_detail"] = options["camera_detail"] soft_camera_directive, soft_camera_config = _camera_directive(soft_camera_config) hard_camera_directive, hard_camera_config = _camera_directive(hard_camera_config) soft_camera_sentence = f"Camera control: {soft_camera_directive} " if soft_camera_directive else "" hard_camera_sentence = f"Camera control: {hard_camera_directive} " if hard_camera_directive else "" hard_scene = soft_row["scene_text"] if options["continuity"] == "same_creator_same_room" else hard_row["scene_text"] - hard_composition = soft_row["composition"] if options["continuity"] == "same_creator_same_room" else hard_row["composition"] + hard_composition = hard_row["composition"] soft_cast = ( - "solo creator setup; the primary creator is alone in the softcore version" + "solo creator setup with Woman A alone" if options["softcore_cast"] == "solo" - else f"non-explicit teaser setup with the same adult cast as the hardcore version: {_insta_of_cast_phrase(hard_women_count, hard_men_count)}" + else f"non-explicit teaser setup with {_insta_of_cast_phrase(hard_women_count, hard_men_count)}" ) soft_cast_presence = ( - "Keep the same cast together in the softcore version in a non-explicit teaser pose with no sex act or genital contact. " + "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}. Shared softcore cast pose: {soft_partner_styling['pose']}. " + f"Partner softcore styling: {soft_partner_outfit_text}. Cast pose: {soft_partner_styling['pose']}. " if options["softcore_cast"] == "same_as_hardcore" and soft_partner_outfit_text else "" ) hard_cast = _insta_of_cast_phrase(hard_women_count, hard_men_count) + hard_clothing_state = _insta_of_hardcore_clothing_state( + options["hardcore_clothing_continuity"], + soft_row["item"], + ) + soft_descriptor_sentence = ( + f"Cast descriptors: {soft_cast_descriptor_text}. " + if options["softcore_cast"] == "same_as_hardcore" + else f"Woman A: {descriptor}. " + ) 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"Insta/OF softcore mode: {platform_style}. " + f"{soft_descriptor_sentence}" + f"Softcore setup: {soft_level}. Cast: {soft_cast}. " f"{soft_cast_presence}" f"{soft_cast_styling_sentence}" f"Outfit: {soft_row['item']}. Pose: {soft_row['pose']}. Setting: {soft_row['scene_text']}. " @@ -3075,10 +3320,11 @@ def build_insta_of_pair( f"{soft_row['positive_suffix']}." ) hard_prompt = ( - f"Insta/OF hardcore mode: {platform_style}. Shared primary creator descriptor: {descriptor}. " + f"Insta/OF hardcore mode: {platform_style}. " 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"Cast descriptors: {cast_descriptor_text}. " + "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"{hard_camera_sentence}" @@ -3103,20 +3349,29 @@ def build_insta_of_pair( soft_partner_styling["pose"], soft_row["scene_text"], soft_row["composition"], - f"{soft_camera_config['camera_mode'].replace('_', ' ')} camera", + f"{soft_camera_config['camera_mode'].replace('_', ' ')} camera" if soft_camera_directive else "", ] soft_caption = ", ".join(str(part).strip() for part in soft_caption_parts if str(part).strip()) - hard_caption = ( - f"{active_trigger}, Insta/OF hardcore mode, same primary creator descriptor, {descriptor}, " - f"{hard_cast}, {hard_row['role_graph']}, {hard_row['item']}, {hard_scene}, {hard_composition}, " - f"{hard_camera_config['camera_mode'].replace('_', ' ')} camera" - ) + hard_caption_parts = [ + active_trigger, + "Insta/OF hardcore mode", + "Woman A", + descriptor, + hard_cast, + hard_row["role_graph"], + hard_row["item"], + hard_scene, + hard_composition, + f"{hard_camera_config['camera_mode'].replace('_', ' ')} camera" if hard_camera_directive else "", + ] + hard_caption = ", ".join(str(part).strip() for part in hard_caption_parts if str(part).strip()) metadata = { "mode": "Insta/OF", "options": options, "shared_descriptor": descriptor, "shared_cast_descriptors": cast_descriptors, "softcore_partner_styling": soft_partner_styling, + "hardcore_clothing_state": hard_clothing_state, "softcore_prompt": soft_prompt, "hardcore_prompt": hard_prompt, "softcore_negative_prompt": soft_negative,