Add chainable character slot controls

This commit is contained in:
2026-06-24 15:10:24 +02:00
parent cb35e1881f
commit a7743cfd4b
6 changed files with 671 additions and 29 deletions
+456 -12
View File
@@ -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,