From a7743cfd4b5060117717e859a96f44acb2327168 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 15:10:24 +0200 Subject: [PATCH] Add chainable character slot controls --- README.md | 36 ++- __init__.py | 88 +++++++ caption_naturalizer.py | 9 +- categories/sexual_poses.json | 2 +- krea_formatter.py | 97 +++++++- prompt_builder.py | 468 ++++++++++++++++++++++++++++++++++- 6 files changed, 671 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2ce0434..668d0bc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The node is registered as: - `prompt_builder / SxCP Generation Profile` - `prompt_builder / SxCP Advanced Filters` - `prompt_builder / SxCP Prompt Builder From Configs` +- `prompt_builder / SxCP Character Slot` - `prompt_builder / SxCP Character Profile Save` - `prompt_builder / SxCP Character Profile Load` - `prompt_builder / SxCP Caption Naturalizer` @@ -51,7 +52,8 @@ node. For cleaner workflows, use the split nodes: The practical compact workflow is: `Category Preset` + `Cast Control` + `Generation Profile` + optional -`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control`, and `Character Profile` +`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control`, +`Character Slot`, and `Character Profile` into `Prompt Builder From Configs`. An importable default workflow is included at @@ -68,6 +70,29 @@ as one long chain: ## Character Profiles +`SxCP Character Slot` is the scalable per-participant control node. Each slot +defines one woman or man with optional overrides for age, ethnicity, figure, +body/body phrase, skin, hair, and eyes. Leave any field on `random` or blank to +let the generator fill that part from the normal pools; set exact values only +where you want control. + +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: + +`Woman slot 3 -> Woman slot 2 -> Woman slot 1 -> Insta/OF Prompt Pair` + +In that chain, `Woman slot 1` resolves as `Woman A`, `Woman slot 2` resolves as +`Woman B`, and `Woman slot 3` resolves as `Woman C`. Men resolve separately the +same way, so the closest man slot becomes `Man A`. + +Connect the final `character_cast` output to `SxCP Prompt Builder`, +`SxCP Prompt Builder From Configs`, or `SxCP Insta/OF Prompt Pair`. It applies +to JSON/custom single woman/man rows, JSON/custom configured-cast rows such as +`Hardcore sexual poses`, and Insta/OF named casts. The older profile save/load +nodes remain useful for one reusable primary character, but slots are better +when you need different settings for each participant. + `SxCP Character Profile Save` extracts a reusable woman/man profile from `metadata_json` or from manual fields. The profile stores age, body/body phrase, skin, hair, eyes, figure, and subject type. It only writes a file when @@ -173,6 +198,9 @@ Important behavior: `minimal` omits most style text. - For Insta/OF paired metadata, the node returns both `krea_softcore_prompt` and `krea_hardcore_prompt`, with separate softcore and hardcore negatives. +- Insta/OF cast metadata is rewritten as direct named-character prose such as + `Woman A is ...` and `Man A is ...`, so Krea2 does not have to interpret a + `Cast descriptors:` label. It outputs: @@ -197,6 +225,12 @@ 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. +For per-character control, chain `SxCP Character Slot` nodes into the pair +node's `character_cast` input. The nearest woman slot controls the shared +primary creator (`Woman A`) in both softcore and hardcore outputs; additional +woman/man slots fill partner descriptors before random fallback descriptors are +used. + It outputs: - `softcore_prompt` diff --git a/__init__.py b/__init__.py index bbf8ebc..6168987 100644 --- a/__init__.py +++ b/__init__.py @@ -7,6 +7,7 @@ try: build_camera_config_json, build_cast_config_json, build_category_config_json, + build_character_slot_json, build_character_profile_json, build_filter_config_json, build_generation_profile_json, @@ -28,6 +29,11 @@ try: cast_preset_choices, category_preset_choices, category_choices, + character_age_choices, + character_body_choices, + character_ethnicity_choices, + character_figure_choices, + character_label_choices, character_profile_choices, ethnicity_choices, generation_profile_choices, @@ -41,6 +47,7 @@ except ImportError: build_camera_config_json, build_cast_config_json, build_category_config_json, + build_character_slot_json, build_character_profile_json, build_filter_config_json, build_generation_profile_json, @@ -62,6 +69,11 @@ except ImportError: cast_preset_choices, category_preset_choices, category_choices, + character_age_choices, + character_body_choices, + character_ethnicity_choices, + character_figure_choices, + character_label_choices, character_profile_choices, ethnicity_choices, generation_profile_choices, @@ -99,6 +111,7 @@ class SxCPPromptBuilder: "seed_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}), + "character_cast": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -131,6 +144,7 @@ class SxCPPromptBuilder: seed_config="", camera_config="", character_profile="", + character_cast="", extra_positive="", extra_negative="", no_plus_women=False, @@ -161,6 +175,7 @@ class SxCPPromptBuilder: seed_config=seed_config or "", camera_config=camera_config or "", character_profile=character_profile or "", + character_cast=character_cast or "", ) return ( row["prompt"], @@ -474,6 +489,7 @@ class SxCPPromptBuilderFromConfigs: "seed_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}), + "character_cast": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -496,6 +512,7 @@ class SxCPPromptBuilderFromConfigs: seed_config="", camera_config="", character_profile="", + character_cast="", extra_positive="", extra_negative="", ): @@ -510,6 +527,7 @@ class SxCPPromptBuilderFromConfigs: seed_config=seed_config or "", camera_config=camera_config or "", character_profile=character_profile or "", + character_cast=character_cast or "", extra_positive=extra_positive or "", extra_negative=extra_negative or "", ) @@ -523,6 +541,71 @@ class SxCPPromptBuilderFromConfigs: ) +class SxCPCharacterSlot: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "subject_type": (["woman", "man"], {"default": "woman"}), + "label": (character_label_choices(), {"default": "auto_chain"}), + "age": (character_age_choices(), {"default": "random"}), + "manual_age": ("STRING", {"default": ""}), + "ethnicity": (character_ethnicity_choices(), {"default": "random"}), + "figure": (character_figure_choices(), {"default": "random"}), + "body": (character_body_choices(), {"default": "random"}), + "manual_body": ("STRING", {"default": ""}), + "body_phrase": ("STRING", {"default": ""}), + "skin": ("STRING", {"default": ""}), + "hair": ("STRING", {"default": ""}), + "eyes": ("STRING", {"default": ""}), + }, + "optional": { + "character_cast": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("character_cast", "character_slot", "summary", "status") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + enabled, + subject_type, + label, + age, + manual_age, + ethnicity, + figure, + body, + manual_body, + body_phrase, + skin, + hair, + eyes, + character_cast="", + ): + result = build_character_slot_json( + subject_type=subject_type, + label=label, + age=age, + manual_age=manual_age, + ethnicity=ethnicity, + figure=figure, + body=body, + manual_body=manual_body, + body_phrase=body_phrase, + skin=skin, + hair=hair, + eyes=eyes, + enabled=enabled, + character_cast=character_cast or "", + ) + return result["character_cast"], result["character_slot"], result["summary"], result["status"] + + class SxCPCharacterProfileSave: @classmethod def INPUT_TYPES(cls): @@ -821,6 +904,7 @@ class SxCPInstaOFPromptPair: "filter_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}), + "character_cast": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -854,6 +938,7 @@ class SxCPInstaOFPromptPair: filter_config="", camera_config="", character_profile="", + character_cast="", extra_positive="", extra_negative="", no_plus_women=False, @@ -874,6 +959,7 @@ class SxCPInstaOFPromptPair: filter_config=filter_config or "", camera_config=camera_config or "", character_profile=character_profile or "", + character_cast=character_cast or "", extra_positive=extra_positive or "", extra_negative=extra_negative or "", ) @@ -899,6 +985,7 @@ NODE_CLASS_MAPPINGS = { "SxCPGenerationProfile": SxCPGenerationProfile, "SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, + "SxCPCharacterSlot": SxCPCharacterSlot, "SxCPCharacterProfileSave": SxCPCharacterProfileSave, "SxCPCharacterProfileLoad": SxCPCharacterProfileLoad, "SxCPCaptionNaturalizer": SxCPCaptionNaturalizer, @@ -917,6 +1004,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPGenerationProfile": "SxCP Generation Profile", "SxCPAdvancedFilters": "SxCP Advanced Filters", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", + "SxCPCharacterSlot": "SxCP Character Slot", "SxCPCharacterProfileSave": "SxCP Character Profile Save", "SxCPCharacterProfileLoad": "SxCP Character Profile Load", "SxCPCaptionNaturalizer": "SxCP Caption Naturalizer", diff --git a/caption_naturalizer.py b/caption_naturalizer.py index 8df9c62..f223632 100644 --- a/caption_naturalizer.py +++ b/caption_naturalizer.py @@ -17,6 +17,8 @@ PROMPT_FIELD_LABELS = ( "Ages", "Body types", "Cast", + "Cast descriptors", + "Characters", "Scene", "Setting", "Pose", @@ -420,11 +422,14 @@ def _configured_cast_from_row(row: dict[str, Any], detail_level: str, keep_style scene = _row_value(row, "scene_text", ("Setting", "Scene")) expression = _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" style = _row_value(row, "style") if keep_style else "" - parts = [f"{_cap_first(subject)} {verb} shown as a consensual {scene_kind}, with all participants 21+"] - if cast: + parts = [f"{_cap_first(subject)} {verb} shown as a consensual {scene_kind}"] + if cast_descriptor_text: + parts.append(f"The named characters are {cast_descriptor_text}") + if cast and not cast_descriptor_text: parts.append(f"The cast is {cast}") if role_graph: parts.append(role_graph) diff --git a/categories/sexual_poses.json b/categories/sexual_poses.json index b9db929..bd45051 100644 --- a/categories/sexual_poses.json +++ b/categories/sexual_poses.json @@ -340,7 +340,7 @@ "cunnilingus with tongue on pussy", "face-sitting cunnilingus", "sixty-nine oral sex", - "blowjob while another partner watches", + {"text": "blowjob while another partner watches", "min_people": 3}, "pussy licking with thighs spread", "cock sucking with visible saliva", "oral sex with tongue and fingers", diff --git a/krea_formatter.py b/krea_formatter.py index 029f34b..c4c5003 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -14,6 +14,8 @@ PROMPT_FIELD_LABELS = ( "Ages", "Body types", "Cast", + "Cast descriptors", + "Characters", "Scene", "Setting", "Pose", @@ -168,6 +170,65 @@ def _prompt_cast_descriptors(text: str) -> str: return _clean(text).replace("Woman A / primary creator:", "Woman A:") +def _cast_entries(text: str) -> list[tuple[str, str]]: + text = _prompt_cast_descriptors(text) + entries: list[tuple[str, str]] = [] + for part in text.split(";"): + part = _clean(part) + match = re.match(r"^((?:Woman|Man) [A-Z]):\s*(.+)$", part) + if match: + entries.append((match.group(1), _clean(match.group(2)))) + return entries + + +def _label_join(labels: list[str]) -> str: + labels = [_clean(label) for label in labels if _clean(label)] + if not labels: + return "the named adults" + if len(labels) == 1: + return labels[0] + if len(labels) == 2: + return f"{labels[0]} and {labels[1]}" + return f"{', '.join(labels[:-1])}, and {labels[-1]}" + + +def _cast_prose(text: str, central_label: str = "Woman A") -> tuple[str, list[str]]: + entries = _cast_entries(text) + if not entries: + return (f"{central_label} is {_clean(text)}" if _clean(text) else "", []) + labels = [label for label, _descriptor in entries] + count_phrase = "one named adult" if len(entries) == 1 else f"{len(entries)} named adults" + sentences = [f"The scene contains {count_phrase}."] + for label, descriptor in entries: + sentences.append(f"{label} is {descriptor}.") + if central_label in labels: + sentences.append(f"{central_label} is the central subject.") + return " ".join(sentences), labels + + +def _sanitize_scene_text_for_cast(text: Any, labels: list[str]) -> str: + text = _clean(text) + if not text: + return "" + if len(labels) < 3: + text = re.sub(r"\s*(?:while|as)\s+another partner watches\b", "", text, flags=re.IGNORECASE) + text = re.sub(r"\banother partner watches\b", "", text, flags=re.IGNORECASE) + text = re.sub(r"\bwhile blowjob\b", "during a blowjob", text, flags=re.IGNORECASE) + text = re.sub(r"\bfeaturing blowjob\b", "featuring a blowjob", text, flags=re.IGNORECASE) + text = re.sub(r"\s+,", ",", text) + text = re.sub(r"\s{2,}", " ", text).strip(" ,") + return text + + +def _natural_clothing_state(text: Any) -> str: + text = _clean(text) + if not text: + return "" + text = re.sub(r"^Clothing state:\s*", "", text, flags=re.IGNORECASE) + text = re.sub(r";\s*softcore visual reference:\s*", ". Softcore visual reference: ", text, flags=re.IGNORECASE) + return text + + def _clean_age(age: Any) -> str: return _clean(age) @@ -292,10 +353,17 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) if subject_type == "configured_cast" or _clean(row.get("cast_summary")): subject = _clean(row.get("subject_phrase") or primary or "adult sexual scene") cast = _clean(row.get("cast_summary")) + cast_descriptor_text = ( + _clean(row.get("cast_descriptor_text")) + or _prompt_field(_clean(row.get("prompt")), "Characters") + or _prompt_field(_clean(row.get("prompt")), "Cast descriptors") + ) + cast_prose, _cast_labels = _cast_prose(cast_descriptor_text) role_graph = _clean(row.get("role_graph")) parts = [ - f"A consensual explicit adult scene with {subject}, all participants 21+ and visibly adult", - f"The cast includes {cast}" if cast else "", + f"A consensual explicit adult scene with {subject}", + cast_prose, + f"The cast includes {cast}" if cast and not cast_prose else "", role_graph, f"The sexual action is {item}" if item else "", f"The setting is {scene}" if scene else "", @@ -380,9 +448,13 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) if options.get("softcore_cast") == "same_as_hardcore" else f"Woman A: {descriptor}" ) + soft_cast_prose, soft_labels = _cast_prose(soft_cast_descriptor_text) + hard_cast_prose, hard_labels = _cast_prose(cast_descriptor_text) + hard_item = _sanitize_scene_text_for_cast(hard.get("item"), hard_labels) + hard_role_graph = _sanitize_scene_text_for_cast(hard.get("role_graph"), hard_labels) same_soft_cast = options.get("softcore_cast") == "same_as_hardcore" soft_cast_presence = ( - "Woman A and the listed partners are present together in a non-explicit teaser pose, with no sex act or genital contact" + f"{_label_join(soft_labels)} are together in a non-explicit teaser pose, with no sex act or genital contact" if same_soft_cast else "The softcore version focuses on Woman A alone" ) @@ -396,12 +468,11 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str) partner_pose = "" soft_parts = [ - 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 "", + f"Softcore {soft_level or 'creator'} Insta/OF image", + soft_cast_prose, soft_cast_presence, - f"Partner softcore styling: {partner_outfit_text}" if partner_outfit_text else "", - f"Cast pose: {partner_pose}" if partner_pose else "", - f"shown in a {soft_level or 'softcore'} Insta/OF creator image", + partner_outfit_text, + f"The cast is {partner_pose}" if partner_pose else "", 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 "", @@ -411,11 +482,11 @@ 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"{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"{hard_level or 'hardcore'} scene", + hard_cast_prose, + _natural_clothing_state(row.get("hardcore_clothing_state")), + hard_role_graph, + f"The explicit detail shows {hard_item}" if hard_item else "", f"set in {hard_scene}" if hard_scene else "", f"with {hard.get('expression')}" if hard.get("expression") else "", f"framed as {hard_composition}" if hard_composition else "", diff --git a/prompt_builder.py b/prompt_builder.py index 720ef49..a676f89 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -76,6 +76,69 @@ ETHNICITY_FILTER_CHOICES = [ "white_asian", ] +CHARACTER_LABEL_CHOICES = [ + "auto_chain", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", +] +CHARACTER_AGE_CHOICES = ( + ["random", "manual"] + + [f"{age}-year-old adult" for age in range(21, 86)] + + [ + "late 20s adult", + "early 30s adult", + "mid 30s adult", + "late 30s adult", + "early 40s adult", + "mid 40s adult", + "late 40s adult", + "early 50s adult", + "mid 50s adult", + "late 50s adult", + "early 60s adult", + "mid 60s adult", + "late 60s adult", + "early 70s adult", + "mid 70s adult", + "late 70s adult", + "early 80s adult", + ] +) +CHARACTER_BODY_CHOICES = [ + "random", + "manual", + "slim", + "petite adult", + "toned", + "athletic", + "average", + "curvy", + "soft curvy", + "curvy athletic", + "hourglass", + "slim busty", + "busty", + "busty curvy", + "voluptuous", + "plus-size", + "heavyset", + "fat", + "stocky", + "broad", + "muscular", +] +CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"} + CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] GENERIC_POSITIVE_SUFFIX = ( @@ -1221,6 +1284,26 @@ def ethnicity_choices() -> list[str]: return list(ETHNICITY_FILTER_CHOICES) +def character_label_choices() -> list[str]: + return list(CHARACTER_LABEL_CHOICES) + + +def character_age_choices() -> list[str]: + return list(CHARACTER_AGE_CHOICES) + + +def character_body_choices() -> list[str]: + return list(CHARACTER_BODY_CHOICES) + + +def character_ethnicity_choices() -> list[str]: + return ["random"] + list(ETHNICITY_FILTER_CHOICES) + + +def character_figure_choices() -> list[str]: + return ["random", "curvy", "balanced", "bombshell"] + + def camera_detail_choices() -> list[str]: return list(CAMERA_DETAIL_CHOICES) @@ -1631,6 +1714,286 @@ def _load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[st return raw +def _slot_value(value: Any) -> str: + text = str(value or "").strip() + if text.lower() in CHARACTER_RANDOM_TOKENS: + return "" + return text + + +def _slot_manual_or_choice(choice: str, manual_value: str) -> str: + choice = str(choice or "").strip() + manual_value = str(manual_value or "").strip() + if choice == "manual": + return manual_value or "random" + if choice.lower() in CHARACTER_RANDOM_TOKENS: + return "random" + return choice + + +def _normalize_slot_ethnicity(value: Any) -> str: + text = str(value or "").strip() + if text.lower() in CHARACTER_RANDOM_TOKENS: + return "random" + if text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text: + return text + return "random" + + +def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]: + subject_type = str(slot.get("subject_type") or slot.get("subject") or "").strip().lower() + if subject_type not in ("woman", "man"): + subject_type = "woman" + label = str(slot.get("label") or slot.get("label_mode") or "auto_chain").strip() + label = label.replace("Woman ", "").replace("Man ", "").strip().upper() + if label == "AUTO_CHAIN": + label = "auto_chain" + if label not in CHARACTER_LABEL_CHOICES: + label = "auto_chain" + + age = _slot_manual_or_choice(str(slot.get("age") or "random"), str(slot.get("manual_age") or "")) + body = _slot_manual_or_choice(str(slot.get("body") or "random"), str(slot.get("manual_body") or "")) + figure = str(slot.get("figure") or "random").strip() + if figure not in character_figure_choices(): + figure = "random" + + normalized = { + "profile_type": "character_slot", + "subject_type": subject_type, + "label": label, + "age": age, + "ethnicity": _normalize_slot_ethnicity(slot.get("ethnicity")), + "figure": figure, + "body": body, + "body_phrase": _slot_value(slot.get("body_phrase")), + "skin": _slot_value(slot.get("skin")), + "hair": _slot_value(slot.get("hair")), + "eyes": _slot_value(slot.get("eyes")), + } + normalized["summary"] = _character_slot_summary(normalized) + return normalized + + +def _parse_character_cast(character_cast: str | dict[str, Any] | list[Any] | None) -> list[dict[str, Any]]: + if not character_cast: + return [] + if isinstance(character_cast, list): + raw = character_cast + elif isinstance(character_cast, dict): + raw = character_cast + else: + try: + raw = json.loads(str(character_cast)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid character_cast JSON: {exc}") from exc + + if isinstance(raw, list): + slots = raw + elif isinstance(raw, dict) and isinstance(raw.get("slots"), list): + slots = raw["slots"] + elif isinstance(raw, dict) and raw.get("profile_type") == "character_slot": + slots = [raw] + elif isinstance(raw, dict) and raw.get("subject_type") in ("woman", "man"): + slots = [raw] + else: + return [] + return [_normalize_character_slot(slot) for slot in slots if isinstance(slot, dict)] + + +def _character_slot_summary(slot: dict[str, Any]) -> str: + subject = str(slot.get("subject_type") or "woman") + label = str(slot.get("label") or "auto_chain") + label_text = "nearest free label" if label == "auto_chain" else f"{subject.capitalize()} {label}" + parts = [ + subject, + label_text, + f"age={slot.get('age', 'random')}", + f"ethnicity={slot.get('ethnicity', 'random')}", + f"figure={slot.get('figure', 'random')}", + f"body={slot.get('body', 'random')}", + ] + for key in ("body_phrase", "skin", "hair", "eyes"): + value = slot.get(key) + if value: + parts.append(f"{key}={value}") + return "; ".join(parts) + + +def build_character_slot_json( + subject_type: str = "woman", + label: str = "auto_chain", + age: str = "random", + manual_age: str = "", + ethnicity: str = "random", + figure: str = "random", + body: str = "random", + manual_body: str = "", + body_phrase: str = "", + skin: str = "", + hair: str = "", + eyes: str = "", + enabled: bool = True, + character_cast: str | dict[str, Any] | list[Any] | None = "", +) -> dict[str, str]: + existing_slots = _parse_character_cast(character_cast) + slot = _normalize_character_slot( + { + "subject_type": subject_type, + "label": label, + "age": age, + "manual_age": manual_age, + "ethnicity": ethnicity, + "figure": figure, + "body": body, + "manual_body": manual_body, + "body_phrase": body_phrase, + "skin": skin, + "hair": hair, + "eyes": eyes, + } + ) + slots = existing_slots + ([slot] if enabled else []) + cast = { + "profile_type": "character_cast", + "version": 1, + "slots": slots, + } + return { + "character_cast": json.dumps(cast, ensure_ascii=True, sort_keys=True), + "character_slot": json.dumps(slot, ensure_ascii=True, sort_keys=True) if enabled else "", + "summary": slot["summary"] if enabled else "disabled", + "status": f"{len(slots)} slot(s)", + } + + +def _slot_explicit_label(slot: dict[str, Any]) -> str: + label = str(slot.get("label") or "").strip().upper() + if label in CHARACTER_LABEL_CHOICES and label != "AUTO_CHAIN": + return label + return "" + + +def _character_slot_label_map(slots: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + label_map: dict[str, dict[str, Any]] = {} + letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for subject_type, prefix in (("woman", "Woman"), ("man", "Man")): + subject_slots = [slot for slot in slots if slot.get("subject_type") == subject_type] + auto_slots = [slot for slot in subject_slots if not _slot_explicit_label(slot)] + for index, slot in enumerate(reversed(auto_slots)): + if index >= len(letters): + break + label_map[f"{prefix} {letters[index]}"] = slot + for slot in subject_slots: + explicit = _slot_explicit_label(slot) + if explicit: + label_map[f"{prefix} {explicit}"] = slot + return label_map + + +def _context_from_character_slot( + rng: random.Random, + slot: dict[str, Any], + subject_type: str, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, +) -> dict[str, str]: + slot_ethnicity = _slot_value(slot.get("ethnicity")) + slot_figure = _slot_value(slot.get("figure")) + slot_body = _slot_value(slot.get("body")) + effective_ethnicity = slot_ethnicity or ethnicity + effective_figure = slot_figure if slot_figure in ("curvy", "balanced", "bombshell") else figure + effective_no_plus = bool(no_plus_women) and not slot_body + effective_no_black = bool(no_black) and not slot_ethnicity + context = _appearance_for_subject( + rng, + subject_type, + effective_ethnicity, + effective_figure, + effective_no_plus, + effective_no_black, + ) + + age = _slot_value(slot.get("age")) + body_phrase = _slot_value(slot.get("body_phrase")) + if age: + context["age"] = age + if slot_body: + context["body"] = slot_body + if subject_type == "woman": + context["body_phrase"] = _body_phrase(slot_body, context.get("figure", "")) + else: + context["body_phrase"] = f"{slot_body} figure" + if body_phrase: + context["body_phrase"] = body_phrase + for key in ("skin", "hair", "eyes"): + value = _slot_value(slot.get(key)) + if value: + context[key] = value + context["subject_type"] = subject_type + context["subject"] = subject_type + context["subject_phrase"] = subject_type + return context + + +def _character_context_for_label( + label: str, + label_map: dict[str, dict[str, Any]], + rng: random.Random, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, +) -> tuple[dict[str, str], dict[str, Any] | None]: + subject_type = "man" if label.startswith("Man ") else "woman" + slot = label_map.get(label) + if slot: + return _context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot + return _appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None + + +def _apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + for key in ("subject_type", "subject", "subject_phrase", "age", "body", "body_phrase", "skin", "hair", "eyes", "figure"): + value = context.get(key) + if value: + row[key] = value + if context.get("age"): + row["age_band"] = context["age"] + return row + + +def _cast_descriptor_entries( + 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, + character_cast: str | dict[str, Any] | list[Any] | None = "", + primary_descriptor: str = "", +) -> tuple[list[str], list[dict[str, Any]]]: + slots = _parse_character_cast(character_cast) + label_map = _character_slot_label_map(slots) + rng = _axis_rng(seed_config, "person", seed, row_number + 997) + descriptors: list[str] = [] + for index in range(max(0, women_count)): + label = f"Woman {chr(ord('A') + index)}" + if index == 0 and primary_descriptor: + descriptors.append(f"Woman A / primary creator: {primary_descriptor}") + continue + context, _slot = _character_context_for_label(label, label_map, rng, ethnicity, figure, no_plus_women, no_black) + descriptors.append(f"{label}: {_insta_of_descriptor_from_context(context)}") + for index in range(max(0, men_count)): + label = f"Man {chr(ord('A') + index)}" + context, _slot = _character_context_for_label(label, label_map, rng, ethnicity, figure, no_plus_women, no_black) + descriptors.append(f"{label}: {_insta_of_descriptor_from_context(context)}") + return descriptors, slots + + def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]: row = _load_json_object(metadata_json, "metadata_json") if isinstance(row.get("softcore_row"), dict): @@ -2432,6 +2795,7 @@ def _build_custom_row( seed_config: dict[str, int], expression_intensity: float, character_profile: str | dict[str, Any] | None = None, + character_cast: str | dict[str, Any] | list[Any] | None = None, ) -> dict[str, Any]: categories = load_category_library() category_rng = _axis_rng(seed_config, "category", seed, row_number) @@ -2469,9 +2833,46 @@ def _build_custom_row( item_text, item_name, item_axis_values = _compose_item(content_rng, category, subcategory, item, women_count, men_count) subject_type = str(_merged_field(category, subcategory, item, "subject_type", "single_any")) context = _subject_context(person_rng, subject_type, ethnicity, figure, no_plus_women, no_black, women_count, men_count) - context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) + character_slots = _parse_character_cast(character_cast) + character_slot_map = _character_slot_label_map(character_slots) + applied_slot: dict[str, Any] = {} + slot_status = "none" + if context.get("subject_type") in ("woman", "man"): + slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A" + if slot_label in character_slot_map: + context, applied_slot = _character_context_for_label( + slot_label, + character_slot_map, + person_rng, + ethnicity, + figure, + no_plus_women, + no_black, + ) + slot_status = f"applied:{slot_label}" + applied_profile, profile_status = {}, "skipped_character_slot" + else: + context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) + else: + context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) subject_type = context["subject_type"] role_graph = _role_graph(role_rng, subcategory, context, item_axis_values) + cast_descriptors: list[str] = [] + cast_descriptor_text = "" + if subject_type == "configured_cast" and character_slots: + cast_descriptors, _descriptor_slots = _cast_descriptor_entries( + seed_config, + seed, + row_number, + ethnicity, + figure, + no_plus_women, + no_black, + women_count, + men_count, + character_slots, + ) + cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors)) scene_slug, scene = _choose_pair(scene_rng, _compatible_entries(_scene_pool(category, subcategory, item, subject_type), women_count, men_count)) pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text( @@ -2523,6 +2924,7 @@ def _build_custom_row( "composition": composition, "composition_prompt": _composition_prompt(composition), "role_graph": role_graph, + "cast_descriptors": cast_descriptor_text, "positive_suffix": positive_suffix, "negative_prompt": negative_prompt, } @@ -2550,7 +2952,11 @@ def _build_custom_row( ) prompt = _format(template, context) + if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in template: + prompt = _insert_positive_directive(prompt, f"Characters: {cast_descriptor_text}.") caption = _format(caption_template, context) + if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in caption_template: + caption = f"{caption.rstrip()}, {cast_descriptor_text}" batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) index = start_index + row_number - 1 row = g.row_base(index, batch, context["subject"], context["age"], context["body"], scene_slug, composition) @@ -2582,6 +2988,8 @@ def _build_custom_row( "content_seed_axis": content_axis, "role_graph": role_graph, "cast_summary": context.get("cast_summary", ""), + "cast_descriptors": cast_descriptors, + "cast_descriptor_text": cast_descriptor_text, "scene_kind": context.get("scene_kind", ""), "women_count": context.get("women_count", ""), "men_count": context.get("men_count", ""), @@ -2589,6 +2997,9 @@ def _build_custom_row( "cast_count_adjustment": count_adjustment if subject_type == "configured_cast" else {}, "character_profile": applied_profile, "character_profile_status": profile_status, + "character_slot": applied_slot, + "character_slot_status": slot_status, + "character_cast_slots": character_slots, "source": "json_category", } ) @@ -2622,6 +3033,7 @@ def build_prompt( camera_config: str | dict[str, Any] | None = None, expression_intensity: float = 0.5, character_profile: str | dict[str, Any] | None = None, + character_cast: str | dict[str, Any] | list[Any] | None = None, ) -> dict[str, Any]: apply_pool_extensions() row_number = max(1, int(row_number)) @@ -2686,6 +3098,7 @@ def build_prompt( parsed_seed_config, expression_intensity, character_profile, + character_cast, ) if extra_positive.strip(): @@ -2710,6 +3123,7 @@ def build_prompt_from_configs( seed_config: str | dict[str, Any] | None = "", camera_config: str | dict[str, Any] | None = "", character_profile: str | dict[str, Any] | None = "", + character_cast: str | dict[str, Any] | list[Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -2742,6 +3156,7 @@ def build_prompt_from_configs( seed_config=seed_config or "", camera_config=camera_config or "", character_profile=character_profile or "", + character_cast=character_cast or "", ) @@ -3060,17 +3475,21 @@ def _insta_of_cast_descriptors( no_black: bool, women_count: int, men_count: int, + character_cast: str | dict[str, Any] | list[Any] | None = "", ) -> 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)}") + descriptors, _slots = _cast_descriptor_entries( + seed_config, + seed, + row_number, + ethnicity, + figure, + no_plus_women, + no_black, + women_count, + men_count, + character_cast, + primary_descriptor=primary_descriptor, + ) return descriptors @@ -3164,6 +3583,7 @@ def build_insta_of_pair( filter_config: str | dict[str, Any] | None = None, camera_config: str | dict[str, Any] | None = None, character_profile: str | dict[str, Any] | None = "", + character_cast: str | dict[str, Any] | list[Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -3177,9 +3597,24 @@ def build_insta_of_pair( 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) + character_slots = _parse_character_cast(character_cast) + character_slot_map = _character_slot_label_map(character_slots) softcore_level_key = str(options["softcore_level"]) soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key) soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311) + soft_person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number) + primary_slot_context = None + primary_slot = character_slot_map.get("Woman A") + if primary_slot: + primary_slot_context = _context_from_character_slot( + soft_person_rng, + primary_slot, + "woman", + ethnicity, + figure, + no_plus_women, + no_black, + ) soft_row = build_prompt( category=soft_category, @@ -3204,8 +3639,13 @@ def build_insta_of_pair( women_count=1, men_count=0, expression_intensity=options["softcore_expression_intensity"], - character_profile=character_profile or "", + character_profile="" if primary_slot else character_profile or "", + character_cast="", ) + 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" 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" @@ -3234,6 +3674,7 @@ def build_insta_of_pair( women_count=hard_women_count, men_count=hard_men_count, expression_intensity=options["hardcore_expression_intensity"], + character_cast=character_cast or "", ) descriptor = _insta_of_descriptor(soft_row) @@ -3248,6 +3689,7 @@ def build_insta_of_pair( no_black, hard_women_count, hard_men_count, + character_slots, ) cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors)) soft_cast_descriptor_text = ( @@ -3382,6 +3824,8 @@ def build_insta_of_pair( "hardcore_row": hard_row, "hardcore_women_count": hard_women_count, "hardcore_men_count": hard_men_count, + "character_cast_slots": character_slots, + "character_slot_labels": sorted(character_slot_map), "softcore_camera_config": soft_camera_config, "hardcore_camera_config": hard_camera_config, "softcore_camera_directive": soft_camera_directive,