Add character descriptor detail controls

This commit is contained in:
2026-06-24 16:56:01 +02:00
parent 253b343c90
commit 5750175eea
3 changed files with 117 additions and 24 deletions
+13
View File
@@ -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 remains available for compatibility and manual mixed use, but the gendered
slots are the cleaner default. 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 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, 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: the next upstream slot becomes `B`, then `C`, and so on. Example:
+11
View File
@@ -32,6 +32,7 @@ try:
category_choices, category_choices,
character_age_choices, character_age_choices,
character_body_choices, character_body_choices,
character_descriptor_detail_choices,
character_ethnicity_choices, character_ethnicity_choices,
character_figure_choices, character_figure_choices,
character_label_choices, character_label_choices,
@@ -75,6 +76,7 @@ except ImportError:
category_choices, category_choices,
character_age_choices, character_age_choices,
character_body_choices, character_body_choices,
character_descriptor_detail_choices,
character_ethnicity_choices, character_ethnicity_choices,
character_figure_choices, character_figure_choices,
character_label_choices, character_label_choices,
@@ -597,6 +599,7 @@ class SxCPCharacterSlot:
"skin": ("STRING", {"default": ""}), "skin": ("STRING", {"default": ""}),
"hair": ("STRING", {"default": ""}), "hair": ("STRING", {"default": ""}),
"eyes": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}),
"descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}),
}, },
"optional": { "optional": {
"character_cast": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}),
@@ -623,6 +626,7 @@ class SxCPCharacterSlot:
skin, skin,
hair, hair,
eyes, eyes,
descriptor_detail="auto",
character_cast="", character_cast="",
): ):
result = build_character_slot_json( result = build_character_slot_json(
@@ -638,6 +642,7 @@ class SxCPCharacterSlot:
skin=skin, skin=skin,
hair=hair, hair=hair,
eyes=eyes, eyes=eyes,
descriptor_detail=descriptor_detail,
enabled=enabled, enabled=enabled,
character_cast=character_cast or "", character_cast=character_cast or "",
) )
@@ -661,6 +666,7 @@ class SxCPWomanSlot:
"skin": ("STRING", {"default": ""}), "skin": ("STRING", {"default": ""}),
"hair": ("STRING", {"default": ""}), "hair": ("STRING", {"default": ""}),
"eyes": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}),
"descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}),
}, },
"optional": { "optional": {
"character_cast": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}),
@@ -686,6 +692,7 @@ class SxCPWomanSlot:
skin, skin,
hair, hair,
eyes, eyes,
descriptor_detail="auto",
character_cast="", character_cast="",
): ):
result = build_character_slot_json( result = build_character_slot_json(
@@ -701,6 +708,7 @@ class SxCPWomanSlot:
skin=skin, skin=skin,
hair=hair, hair=hair,
eyes=eyes, eyes=eyes,
descriptor_detail=descriptor_detail,
enabled=enabled, enabled=enabled,
character_cast=character_cast or "", character_cast=character_cast or "",
) )
@@ -723,6 +731,7 @@ class SxCPManSlot:
"skin": ("STRING", {"default": ""}), "skin": ("STRING", {"default": ""}),
"hair": ("STRING", {"default": ""}), "hair": ("STRING", {"default": ""}),
"eyes": ("STRING", {"default": ""}), "eyes": ("STRING", {"default": ""}),
"descriptor_detail": (character_descriptor_detail_choices(), {"default": "compact"}),
}, },
"optional": { "optional": {
"character_cast": ("STRING", {"default": "", "multiline": True}), "character_cast": ("STRING", {"default": "", "multiline": True}),
@@ -747,6 +756,7 @@ class SxCPManSlot:
skin, skin,
hair, hair,
eyes, eyes,
descriptor_detail="compact",
character_cast="", character_cast="",
): ):
result = build_character_slot_json( result = build_character_slot_json(
@@ -762,6 +772,7 @@ class SxCPManSlot:
skin=skin, skin=skin,
hair=hair, hair=hair,
eyes=eyes, eyes=eyes,
descriptor_detail=descriptor_detail,
enabled=enabled, enabled=enabled,
character_cast=character_cast or "", character_cast=character_cast or "",
) )
+92 -23
View File
@@ -174,6 +174,7 @@ CHARACTER_MAN_BODY_CHOICES = [
"heavyset", "heavyset",
"fat", "fat",
] ]
CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"]
CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"} CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
@@ -1366,6 +1367,10 @@ def character_man_body_choices() -> list[str]:
return list(CHARACTER_MAN_BODY_CHOICES) 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]: def character_ethnicity_choices() -> list[str]:
return ["random"] + list(ETHNICITY_FILTER_CHOICES) return ["random"] + list(ETHNICITY_FILTER_CHOICES)
@@ -1791,6 +1796,44 @@ def _slot_value(value: Any) -> str:
return text 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: def _slot_manual_or_choice(choice: str, manual_value: str) -> str:
choice = str(choice or "").strip() choice = str(choice or "").strip()
manual_value = str(manual_value 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")), "skin": _slot_value(slot.get("skin")),
"hair": _slot_value(slot.get("hair")), "hair": _slot_value(slot.get("hair")),
"eyes": _slot_value(slot.get("eyes")), "eyes": _slot_value(slot.get("eyes")),
"descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")),
} }
normalized["summary"] = _character_slot_summary(normalized) normalized["summary"] = _character_slot_summary(normalized)
return normalized return normalized
@@ -1881,6 +1925,7 @@ def _character_slot_summary(slot: dict[str, Any]) -> str:
f"ethnicity={slot.get('ethnicity', 'random')}", f"ethnicity={slot.get('ethnicity', 'random')}",
f"figure={slot.get('figure', 'random')}", f"figure={slot.get('figure', 'random')}",
f"body={slot.get('body', 'random')}", f"body={slot.get('body', 'random')}",
f"detail={slot.get('descriptor_detail', 'auto')}",
] ]
for key in ("body_phrase", "skin", "hair", "eyes"): for key in ("body_phrase", "skin", "hair", "eyes"):
value = slot.get(key) value = slot.get(key)
@@ -1902,6 +1947,7 @@ def build_character_slot_json(
skin: str = "", skin: str = "",
hair: str = "", hair: str = "",
eyes: str = "", eyes: str = "",
descriptor_detail: str = "auto",
enabled: bool = True, enabled: bool = True,
character_cast: str | dict[str, Any] | list[Any] | None = "", character_cast: str | dict[str, Any] | list[Any] | None = "",
) -> dict[str, str]: ) -> dict[str, str]:
@@ -1920,6 +1966,7 @@ def build_character_slot_json(
"skin": skin, "skin": skin,
"hair": hair, "hair": hair,
"eyes": eyes, "eyes": eyes,
"descriptor_detail": descriptor_detail,
} }
) )
slots = existing_slots + ([slot] if enabled else []) slots = existing_slots + ([slot] if enabled else [])
@@ -2001,6 +2048,7 @@ def _context_from_character_slot(
value = _slot_value(slot.get(key)) value = _slot_value(slot.get(key))
if value: if value:
context[key] = value context[key] = value
context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail"))
context["subject_type"] = subject_type context["subject_type"] = subject_type
context["subject"] = subject_type context["subject"] = subject_type
context["subject_phrase"] = 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]: 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) value = context.get(key)
if value: if value:
row[key] = 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: def _character_profile_descriptor(profile: dict[str, Any]) -> str:
subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip() subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip()
age = str(profile.get("age") or "").strip() return _descriptor_from_parts(
age = age.removesuffix(" adults").removesuffix(" adult").strip() subject,
subject_phrase = f"{age} adult {subject}".strip() if age else f"adult {subject}" profile.get("age"),
pieces = [
subject_phrase,
profile.get("body_phrase") or _body_phrase(profile.get("body"), profile.get("figure")), profile.get("body_phrase") or _body_phrase(profile.get("body"), profile.get("figure")),
profile.get("skin"), profile.get("skin"),
profile.get("hair"), profile.get("hair"),
profile.get("eyes"), profile.get("eyes"),
] profile.get("descriptor_detail"),
return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) )
def _normalize_character_profile(profile: dict[str, Any], profile_name: str = "") -> dict[str, Any]: 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(), "hair": str(profile.get("hair") or "").strip(),
"eyes": str(profile.get("eyes") or "").strip(), "eyes": str(profile.get("eyes") or "").strip(),
"figure": figure, "figure": figure,
"descriptor_detail": _normalize_descriptor_detail(profile.get("descriptor_detail")),
} }
normalized["descriptor"] = _character_profile_descriptor(normalized) normalized["descriptor"] = _character_profile_descriptor(normalized)
return normalized return normalized
@@ -2137,6 +2196,7 @@ def build_character_profile_json(
"hair": row.get("hair") or hair, "hair": row.get("hair") or hair,
"eyes": row.get("eyes") or eyes, "eyes": row.get("eyes") or eyes,
"figure": row.get("figure") or figure, "figure": row.get("figure") or figure,
"descriptor_detail": row.get("descriptor_detail") or "auto",
} }
else: else:
raw_profile = { raw_profile = {
@@ -2149,6 +2209,7 @@ def build_character_profile_json(
"hair": hair, "hair": hair,
"eyes": eyes, "eyes": eyes,
"figure": figure, "figure": figure,
"descriptor_detail": "auto",
} }
profile = _normalize_character_profile(raw_profile, profile_name) profile = _normalize_character_profile(raw_profile, profile_name)
saved_path = "" saved_path = ""
@@ -2257,7 +2318,19 @@ def _apply_character_profile_to_context(
if profile["subject_type"] != context.get("subject_type"): if profile["subject_type"] != context.get("subject_type"):
return context, profile, "skipped_subject_mismatch" return context, profile, "skipped_subject_mismatch"
updated = dict(context) 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) value = profile.get(key)
if value: if value:
updated[key] = 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: def _insta_of_descriptor(row: dict[str, Any]) -> str:
age = str(row.get("age_band") or row.get("age") or "").strip() return _descriptor_from_parts(
age = " ".join(age.split()) "woman",
age = age.removesuffix(" adults").removesuffix(" adult").strip() row.get("age_band") or row.get("age"),
pieces = [
f"{age} adult woman" if age else "adult woman",
row.get("body_phrase"), row.get("body_phrase"),
row.get("skin"), row.get("skin"),
row.get("hair"), row.get("hair"),
row.get("eyes"), row.get("eyes"),
] row.get("descriptor_detail"),
return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) )
def _insta_of_descriptor_from_context(context: dict[str, Any]) -> str: 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() subject = str(context.get("subject") or context.get("subject_type") or "person").strip()
pieces = [ return _descriptor_from_parts(
f"{age} adult {subject}".strip(), subject,
context.get("age"),
context.get("body_phrase"), context.get("body_phrase"),
context.get("skin"), context.get("skin"),
context.get("hair"), context.get("hair"),
context.get("eyes"), context.get("eyes"),
] context.get("descriptor_detail"),
return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) )
def _insta_of_cast_descriptors( def _insta_of_cast_descriptors(