From 5750175eeaea13b04c434368eb2448f3569163b9 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 16:56:01 +0200 Subject: [PATCH] Add character descriptor detail controls --- README.md | 13 ++++++ __init__.py | 11 +++++ prompt_builder.py | 117 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 117 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 55f500b..4d8a0d5 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,19 @@ body choices and omits figure bias. The older generic `SxCP Character Slot` remains available for compatibility and manual mixed use, but the gendered slots are the cleaner default. +Each slot also has `descriptor_detail`, which controls how much appearance text +is emitted into named-cast descriptors: + +- `auto`: women use `full`; men use `compact`. +- `full`: age, body, skin, hair, and eyes. +- `medium`: age, body, skin, and hair. +- `compact`: age, body, and skin. +- `minimal`: age and body only. + +`SxCP Man Slot` defaults to `compact`, which keeps men readable in Krea-style +couple/group prompts without turning every partner into a fully detailed primary +character. Set a man slot to `full` when the partner needs exact hair/eye detail. + Slots 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: diff --git a/__init__.py b/__init__.py index d7056d1..0ef0b05 100644 --- a/__init__.py +++ b/__init__.py @@ -32,6 +32,7 @@ try: category_choices, character_age_choices, character_body_choices, + character_descriptor_detail_choices, character_ethnicity_choices, character_figure_choices, character_label_choices, @@ -75,6 +76,7 @@ except ImportError: category_choices, character_age_choices, character_body_choices, + character_descriptor_detail_choices, character_ethnicity_choices, character_figure_choices, character_label_choices, @@ -597,6 +599,7 @@ class SxCPCharacterSlot: "skin": ("STRING", {"default": ""}), "hair": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}), + "descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -623,6 +626,7 @@ class SxCPCharacterSlot: skin, hair, eyes, + descriptor_detail="auto", character_cast="", ): result = build_character_slot_json( @@ -638,6 +642,7 @@ class SxCPCharacterSlot: skin=skin, hair=hair, eyes=eyes, + descriptor_detail=descriptor_detail, enabled=enabled, character_cast=character_cast or "", ) @@ -661,6 +666,7 @@ class SxCPWomanSlot: "skin": ("STRING", {"default": ""}), "hair": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}), + "descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -686,6 +692,7 @@ class SxCPWomanSlot: skin, hair, eyes, + descriptor_detail="auto", character_cast="", ): result = build_character_slot_json( @@ -701,6 +708,7 @@ class SxCPWomanSlot: skin=skin, hair=hair, eyes=eyes, + descriptor_detail=descriptor_detail, enabled=enabled, character_cast=character_cast or "", ) @@ -723,6 +731,7 @@ class SxCPManSlot: "skin": ("STRING", {"default": ""}), "hair": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}), + "descriptor_detail": (character_descriptor_detail_choices(), {"default": "compact"}), }, "optional": { "character_cast": ("STRING", {"default": "", "multiline": True}), @@ -747,6 +756,7 @@ class SxCPManSlot: skin, hair, eyes, + descriptor_detail="compact", character_cast="", ): result = build_character_slot_json( @@ -762,6 +772,7 @@ class SxCPManSlot: skin=skin, hair=hair, eyes=eyes, + descriptor_detail=descriptor_detail, enabled=enabled, character_cast=character_cast or "", ) diff --git a/prompt_builder.py b/prompt_builder.py index 84eb5c9..7ef6489 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -174,6 +174,7 @@ CHARACTER_MAN_BODY_CHOICES = [ "heavyset", "fat", ] +CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"] CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"} CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] @@ -1366,6 +1367,10 @@ def character_man_body_choices() -> list[str]: return list(CHARACTER_MAN_BODY_CHOICES) +def character_descriptor_detail_choices() -> list[str]: + return list(CHARACTER_DESCRIPTOR_DETAIL_CHOICES) + + def character_ethnicity_choices() -> list[str]: return ["random"] + list(ETHNICITY_FILTER_CHOICES) @@ -1791,6 +1796,44 @@ def _slot_value(value: Any) -> str: return text +def _normalize_descriptor_detail(value: Any) -> str: + text = str(value or "auto").strip() + return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto" + + +def _descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str: + detail = _normalize_descriptor_detail(descriptor_detail) + if detail != "auto": + return detail + return "compact" if str(subject or "").strip().lower() == "man" else "full" + + +def _descriptor_from_parts( + subject: Any, + age: Any, + body_phrase: Any, + skin: Any, + hair: Any, + eyes: Any, + descriptor_detail: Any = "auto", +) -> str: + subject = str(subject or "person").strip() or "person" + age_text = " ".join(str(age or "").strip().split()) + age_text = age_text.removesuffix(" adults").removesuffix(" adult").strip() + if age_text in ("adult", "adults"): + age_text = "" + subject_phrase = f"{age_text} adult {subject}".strip() if age_text else f"adult {subject}" + detail = _descriptor_detail_for_subject(subject, descriptor_detail) + detail_map = { + "minimal": (body_phrase,), + "compact": (body_phrase, skin), + "medium": (body_phrase, skin, hair), + "full": (body_phrase, skin, hair, eyes), + } + pieces = [subject_phrase, *detail_map.get(detail, detail_map["full"])] + return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + + def _slot_manual_or_choice(choice: str, manual_value: str) -> str: choice = str(choice or "").strip() manual_value = str(manual_value or "").strip() @@ -1839,6 +1882,7 @@ def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]: "skin": _slot_value(slot.get("skin")), "hair": _slot_value(slot.get("hair")), "eyes": _slot_value(slot.get("eyes")), + "descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")), } normalized["summary"] = _character_slot_summary(normalized) return normalized @@ -1881,6 +1925,7 @@ def _character_slot_summary(slot: dict[str, Any]) -> str: f"ethnicity={slot.get('ethnicity', 'random')}", f"figure={slot.get('figure', 'random')}", f"body={slot.get('body', 'random')}", + f"detail={slot.get('descriptor_detail', 'auto')}", ] for key in ("body_phrase", "skin", "hair", "eyes"): value = slot.get(key) @@ -1902,6 +1947,7 @@ def build_character_slot_json( skin: str = "", hair: str = "", eyes: str = "", + descriptor_detail: str = "auto", enabled: bool = True, character_cast: str | dict[str, Any] | list[Any] | None = "", ) -> dict[str, str]: @@ -1920,6 +1966,7 @@ def build_character_slot_json( "skin": skin, "hair": hair, "eyes": eyes, + "descriptor_detail": descriptor_detail, } ) slots = existing_slots + ([slot] if enabled else []) @@ -2001,6 +2048,7 @@ def _context_from_character_slot( value = _slot_value(slot.get(key)) if value: context[key] = value + context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail")) context["subject_type"] = subject_type context["subject"] = subject_type context["subject_phrase"] = subject_type @@ -2024,7 +2072,19 @@ def _character_context_for_label( 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"): + for key in ( + "subject_type", + "subject", + "subject_phrase", + "age", + "body", + "body_phrase", + "skin", + "hair", + "eyes", + "figure", + "descriptor_detail", + ): value = context.get(key) if value: row[key] = value @@ -2073,17 +2133,15 @@ def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> di def _character_profile_descriptor(profile: dict[str, Any]) -> str: subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip() - age = str(profile.get("age") or "").strip() - age = age.removesuffix(" adults").removesuffix(" adult").strip() - subject_phrase = f"{age} adult {subject}".strip() if age else f"adult {subject}" - pieces = [ - subject_phrase, + return _descriptor_from_parts( + subject, + profile.get("age"), profile.get("body_phrase") or _body_phrase(profile.get("body"), profile.get("figure")), profile.get("skin"), profile.get("hair"), profile.get("eyes"), - ] - return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + profile.get("descriptor_detail"), + ) def _normalize_character_profile(profile: dict[str, Any], profile_name: str = "") -> dict[str, Any]: @@ -2106,6 +2164,7 @@ def _normalize_character_profile(profile: dict[str, Any], profile_name: str = "" "hair": str(profile.get("hair") or "").strip(), "eyes": str(profile.get("eyes") or "").strip(), "figure": figure, + "descriptor_detail": _normalize_descriptor_detail(profile.get("descriptor_detail")), } normalized["descriptor"] = _character_profile_descriptor(normalized) return normalized @@ -2137,6 +2196,7 @@ def build_character_profile_json( "hair": row.get("hair") or hair, "eyes": row.get("eyes") or eyes, "figure": row.get("figure") or figure, + "descriptor_detail": row.get("descriptor_detail") or "auto", } else: raw_profile = { @@ -2149,7 +2209,8 @@ def build_character_profile_json( "hair": hair, "eyes": eyes, "figure": figure, - } + "descriptor_detail": "auto", + } profile = _normalize_character_profile(raw_profile, profile_name) saved_path = "" status = "not_saved" @@ -2257,7 +2318,19 @@ def _apply_character_profile_to_context( if profile["subject_type"] != context.get("subject_type"): return context, profile, "skipped_subject_mismatch" updated = dict(context) - for key in ("subject_type", "subject", "subject_phrase", "age", "body", "body_phrase", "skin", "hair", "eyes", "figure"): + for key in ( + "subject_type", + "subject", + "subject_phrase", + "age", + "body", + "body_phrase", + "skin", + "hair", + "eyes", + "figure", + "descriptor_detail", + ): value = profile.get(key) if value: updated[key] = value @@ -3567,32 +3640,28 @@ def _insta_of_hardcore_counts(options: dict[str, Any]) -> tuple[int, int]: def _insta_of_descriptor(row: dict[str, Any]) -> str: - age = str(row.get("age_band") or row.get("age") or "").strip() - age = " ".join(age.split()) - age = age.removesuffix(" adults").removesuffix(" adult").strip() - pieces = [ - f"{age} adult woman" if age else "adult woman", + return _descriptor_from_parts( + "woman", + row.get("age_band") or row.get("age"), row.get("body_phrase"), row.get("skin"), row.get("hair"), row.get("eyes"), - ] - return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + row.get("descriptor_detail"), + ) def _insta_of_descriptor_from_context(context: dict[str, Any]) -> str: - age = str(context.get("age") or "").strip() - age = " ".join(age.split()) - age = age.removesuffix(" adults").removesuffix(" adult").strip() subject = str(context.get("subject") or context.get("subject_type") or "person").strip() - pieces = [ - f"{age} adult {subject}".strip(), + return _descriptor_from_parts( + subject, + context.get("age"), context.get("body_phrase"), context.get("skin"), context.get("hair"), context.get("eyes"), - ] - return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + context.get("descriptor_detail"), + ) def _insta_of_cast_descriptors(