Extract character slot policy

This commit is contained in:
2026-06-27 08:30:41 +02:00
parent 3f251a6bb7
commit e9cc75bd5f
7 changed files with 484 additions and 244 deletions
+46 -237
View File
@@ -28,6 +28,7 @@ try:
from . import category_template_metadata as item_template_policy
from . import character_config as character_policy
from . import character_profile as character_profile_policy
from . import character_slot as character_slot_policy
from . import category_cast_config as category_cast_policy
from . import filter_config as filter_policy
from . import generate_prompt_batches as g
@@ -72,6 +73,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
import category_template_metadata as item_template_policy
import character_config as character_policy
import character_profile as character_profile_policy
import character_slot as character_slot_policy
import category_cast_config as category_cast_policy
import filter_config as filter_policy
import generate_prompt_batches as g
@@ -1240,7 +1242,7 @@ def character_ethnicity_choices() -> list[str]:
def character_figure_choices() -> list[str]:
return ["random", "curvy", "balanced", "bombshell"]
return character_policy.character_figure_choices()
def camera_detail_choices() -> list[str]:
@@ -1798,37 +1800,19 @@ def _slot_is_pov(slot: dict[str, Any] | None) -> bool:
def _normalize_slot_expression_intensity(value: Any) -> float:
try:
intensity = float(value)
except (TypeError, ValueError):
return -1.0
if intensity < 0:
return -1.0
return _clamped_float(intensity, 0.5)
return character_slot_policy.normalize_slot_expression_intensity(value)
def _slot_expression_enabled(slot: dict[str, Any] | None) -> bool:
if not slot:
return True
return not _is_false(slot.get("expression_enabled", True))
return character_slot_policy.slot_expression_enabled(slot)
def _slot_expression_intensity(slot: dict[str, Any] | None) -> float | None:
if not slot or not _slot_expression_enabled(slot):
return None
intensity = _normalize_slot_expression_intensity(slot.get("expression_intensity"))
return intensity if intensity >= 0 else None
return character_slot_policy.slot_expression_intensity(slot)
def _slot_expression_intensity_for_phase(slot: dict[str, Any] | None, phase: str = "") -> float | None:
if not slot or not _slot_expression_enabled(slot):
return None
phase_key = f"{phase}_expression_intensity" if phase in ("softcore", "hardcore") else ""
if phase_key:
intensity = _normalize_slot_expression_intensity(slot.get(phase_key))
if intensity >= 0:
return intensity
return _slot_expression_intensity(slot)
return character_slot_policy.slot_expression_intensity_for_phase(slot, phase)
def _normalize_slot_seed(value: Any) -> int:
@@ -1836,20 +1820,15 @@ def _normalize_slot_seed(value: Any) -> int:
def _slot_seed(slot: dict[str, Any] | None) -> int:
if not slot:
return -1
return _normalize_slot_seed(slot.get("slot_seed"))
return character_slot_policy.slot_seed(slot)
def _slot_seeded_rng(slot: dict[str, Any] | None, salt: int) -> random.Random | None:
seed = _slot_seed(slot)
if seed < 0:
return None
return random.Random(_row_seed(seed, 1, salt))
return character_slot_policy.slot_seeded_rng(slot, salt)
def _slot_context_rng(slot: dict[str, Any], fallback_rng: random.Random) -> random.Random:
return _slot_seeded_rng(slot, 701) or fallback_rng
return character_slot_policy.slot_context_rng(slot, fallback_rng)
def _slot_effective_figure(
@@ -1857,13 +1836,7 @@ def _slot_effective_figure(
subject_type: str,
fallback_figure: str,
) -> str:
raw_figure = str(slot.get("figure") or "random").strip()
if raw_figure in ("curvy", "balanced", "bombshell"):
return raw_figure
seeded_rng = _slot_seeded_rng(slot, 709)
if subject_type == "woman" and seeded_rng is not None:
return g.choose(seeded_rng, ["curvy", "balanced", "bombshell"])
return fallback_figure
return character_slot_policy.slot_effective_figure(slot, subject_type, fallback_figure)
def _mean(values: list[float]) -> float:
@@ -2074,17 +2047,11 @@ def _descriptor_from_parts(
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
return character_slot_policy.slot_manual_or_choice(choice, manual_value)
def _normalize_slot_ethnicity(value: Any) -> str:
return normalize_ethnicity_filter(value, "random", allow_random=True)
return character_slot_policy.normalize_slot_ethnicity(value)
def _normalize_hair_choice(value: Any, choices: list[str]) -> str:
@@ -2198,160 +2165,15 @@ def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: 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"
manual_config = _parse_character_manual_config(slot.get("manual") or slot.get("manual_config"))
raw_age = str(slot.get("age") or "random")
raw_manual_age = str(slot.get("manual_age") or "").strip()
if not raw_manual_age and manual_config.get("manual_age"):
raw_manual_age = manual_config["manual_age"]
if raw_age.lower() in CHARACTER_RANDOM_TOKENS:
raw_age = "manual"
age = _slot_manual_or_choice(raw_age, raw_manual_age)
raw_body = str(slot.get("body") or "random")
raw_manual_body = str(slot.get("manual_body") or "").strip()
if not raw_manual_body and manual_config.get("manual_body"):
raw_manual_body = manual_config["manual_body"]
if raw_body.lower() in CHARACTER_RANDOM_TOKENS:
raw_body = "manual"
body = _slot_manual_or_choice(raw_body, raw_manual_body)
figure = str(slot.get("figure") or "random").strip()
if figure not in character_figure_choices():
figure = "random"
def manual_fallback(field: str) -> str:
direct = _slot_value(slot.get(field))
return direct or manual_config.get(field, "")
normalized = {
"profile_type": "character_slot",
"subject_type": subject_type,
"label": label,
"slot_seed": _normalize_slot_seed(slot.get("slot_seed")),
"age": age,
"ethnicity": _normalize_slot_ethnicity(slot.get("ethnicity")),
"figure": figure,
"body": body,
"body_phrase": manual_fallback("body_phrase"),
"skin": manual_fallback("skin"),
"hair": manual_fallback("hair"),
"manual": manual_config,
"characteristics": (
slot.get("characteristics")
if isinstance(slot.get("characteristics"), dict)
else _slot_value(slot.get("characteristics") or slot.get("characteristics_config"))
),
"hair_config": (
slot.get("hair_config")
if isinstance(slot.get("hair_config"), dict)
else _slot_value(slot.get("hair_config"))
),
"hair_color": _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES),
"hair_length": _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES),
"hair_style": _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES),
"eyes": manual_fallback("eyes"),
"descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")),
"presence_mode": _normalize_presence_mode(slot.get("presence_mode"), subject_type),
"softcore_outfit": manual_fallback("softcore_outfit"),
"hardcore_clothing": (
_slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit"))
or manual_config.get("hardcore_clothing", "")
),
"expression_enabled": not _is_false(slot.get("expression_enabled", True)),
"expression_intensity": _normalize_slot_expression_intensity(slot.get("expression_intensity")),
"softcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("softcore_expression_intensity")),
"hardcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("hardcore_expression_intensity")),
}
normalized["summary"] = _character_slot_summary(normalized)
return normalized
return character_slot_policy.normalize_character_slot(slot)
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)]
return character_slot_policy.parse_character_cast(character_cast)
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"seed={slot.get('slot_seed')}" if _slot_seed(slot) >= 0 else "",
f"age={slot.get('age', 'random')}",
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')}",
]
parts = [part for part in parts if part]
if _slot_is_pov(slot):
parts.append("presence=pov")
if not _slot_expression_enabled(slot):
parts.append("expression=disabled")
else:
expression_intensity = _slot_expression_intensity(slot)
if expression_intensity is not None:
parts.append(f"expression={expression_intensity:.2f}")
softcore_expression_intensity = _slot_expression_intensity_for_phase(slot, "softcore")
hardcore_expression_intensity = _slot_expression_intensity_for_phase(slot, "hardcore")
if softcore_expression_intensity is not None and softcore_expression_intensity != expression_intensity:
parts.append(f"soft_expr={softcore_expression_intensity:.2f}")
if hardcore_expression_intensity is not None and hardcore_expression_intensity != expression_intensity:
parts.append(f"hard_expr={hardcore_expression_intensity:.2f}")
if slot.get("softcore_outfit"):
parts.append(f"soft_outfit={slot['softcore_outfit']}")
if slot.get("hardcore_clothing"):
parts.append(f"hard_clothing={slot['hardcore_clothing']}")
characteristics = _parse_characteristics_config(slot.get("characteristics"))
characteristics_summary = _characteristics_summary(characteristics)
if characteristics_summary != "characteristics unrestricted":
parts.append(f"characteristics={characteristics_summary}")
hair_config = _parse_hair_config(slot.get("hair_config"))
hair_config_summary = _hair_config_summary(hair_config)
if hair_config_summary != "hair unrestricted":
parts.append(f"hair={hair_config_summary}")
for key in ("hair_color", "hair_length", "hair_style"):
value = slot.get(key)
if value and value != "random":
parts.append(f"{key}={value}")
for key in ("body_phrase", "skin", "hair", "eyes"):
value = slot.get(key)
if value:
parts.append(f"{key}={value}")
return "; ".join(parts)
return character_slot_policy.character_slot_summary(slot)
def build_character_slot_json(
@@ -2385,50 +2207,37 @@ def build_character_slot_json(
softcore_outfit: str = "",
hardcore_clothing: str = "",
) -> dict[str, str]:
existing_slots = _parse_character_cast(character_cast)
slot = _normalize_character_slot(
{
"subject_type": subject_type,
"label": label,
"slot_seed": slot_seed,
"age": age,
"manual_age": manual_age,
"manual": manual,
"ethnicity": ethnicity,
"figure": figure,
"body": body,
"manual_body": manual_body,
"body_phrase": body_phrase,
"skin": skin,
"hair": hair,
"characteristics": characteristics,
"hair_config": hair_config,
"hair_color": hair_color,
"hair_length": hair_length,
"hair_style": hair_style,
"eyes": eyes,
"descriptor_detail": descriptor_detail,
"presence_mode": presence_mode,
"softcore_outfit": softcore_outfit,
"hardcore_clothing": hardcore_clothing,
"expression_enabled": expression_enabled,
"expression_intensity": expression_intensity,
"softcore_expression_intensity": softcore_expression_intensity,
"hardcore_expression_intensity": hardcore_expression_intensity,
}
return character_slot_policy.build_character_slot_json(
subject_type=subject_type,
label=label,
slot_seed=slot_seed,
age=age,
manual_age=manual_age,
manual=manual,
ethnicity=ethnicity,
figure=figure,
body=body,
manual_body=manual_body,
body_phrase=body_phrase,
skin=skin,
hair=hair,
characteristics=characteristics,
hair_config=hair_config,
hair_color=hair_color,
hair_length=hair_length,
hair_style=hair_style,
eyes=eyes,
descriptor_detail=descriptor_detail,
expression_enabled=expression_enabled,
expression_intensity=expression_intensity,
enabled=enabled,
character_cast=character_cast,
presence_mode=presence_mode,
softcore_expression_intensity=softcore_expression_intensity,
hardcore_expression_intensity=hardcore_expression_intensity,
softcore_outfit=softcore_outfit,
hardcore_clothing=hardcore_clothing,
)
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: