481 lines
18 KiB
Python
481 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
from . import character_config as character_policy
|
|
except ImportError: # Allows local smoke tests from the repository root.
|
|
import character_config as character_policy
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent
|
|
PROFILE_DIR = ROOT_DIR / "profiles"
|
|
CHARACTER_MANUAL_FIELDS = (
|
|
"manual_age",
|
|
"manual_body",
|
|
"body_phrase",
|
|
"skin",
|
|
"hair",
|
|
"eyes",
|
|
"softcore_outfit",
|
|
"hardcore_clothing",
|
|
)
|
|
|
|
|
|
def body_phrase(body: Any, figure_note: Any = "") -> str:
|
|
body = str(body or "").strip()
|
|
figure_note = str(figure_note or "").strip()
|
|
if not body:
|
|
return figure_note
|
|
if not figure_note:
|
|
return f"{body} figure"
|
|
if "figure" in figure_note.lower():
|
|
return f"{body} build and {figure_note}"
|
|
return f"{body} figure with {figure_note}"
|
|
|
|
|
|
def safe_profile_name(profile_name: str) -> str:
|
|
profile_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(profile_name or "").strip()).strip("_")
|
|
return profile_name[:64] or "profile"
|
|
|
|
|
|
def profile_path(profile_name: str) -> Path:
|
|
return PROFILE_DIR / f"{safe_profile_name(profile_name)}.json"
|
|
|
|
|
|
def character_profile_choices() -> list[str]:
|
|
if not PROFILE_DIR.exists():
|
|
return ["manual"]
|
|
names = sorted(path.stem for path in PROFILE_DIR.glob("*.json") if path.is_file())
|
|
return ["manual"] + names
|
|
|
|
|
|
def load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[str, Any]:
|
|
if not value:
|
|
return {}
|
|
if isinstance(value, dict):
|
|
return value
|
|
try:
|
|
raw = json.loads(str(value))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid {label} JSON: {exc}") from exc
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"{label} must be a JSON object.")
|
|
return raw
|
|
|
|
|
|
def parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]:
|
|
if not value:
|
|
return {}
|
|
if isinstance(value, dict):
|
|
raw = value
|
|
else:
|
|
try:
|
|
raw = json.loads(str(value))
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
return {
|
|
key: str(raw.get(key) or "").strip()
|
|
for key in CHARACTER_MANUAL_FIELDS
|
|
if str(raw.get(key) or "").strip()
|
|
}
|
|
|
|
|
|
def character_manual_summary(config: dict[str, str]) -> str:
|
|
parts = [f"{key}={value}" for key, value in config.items() if value]
|
|
return "; ".join(parts) if parts else "manual unrestricted"
|
|
|
|
|
|
def build_character_manual_config_json(
|
|
manual: str | dict[str, Any] | None = "",
|
|
combine_mode: str = "merge_nonempty",
|
|
manual_age: str = "",
|
|
manual_body: str = "",
|
|
body_phrase: str = "",
|
|
skin: str = "",
|
|
hair: str = "",
|
|
eyes: str = "",
|
|
softcore_outfit: str = "",
|
|
hardcore_clothing: str = "",
|
|
) -> str:
|
|
base = {} if combine_mode == "replace_all" else parse_character_manual_config(manual)
|
|
updates = {
|
|
"manual_age": manual_age,
|
|
"manual_body": manual_body,
|
|
"body_phrase": body_phrase,
|
|
"skin": skin,
|
|
"hair": hair,
|
|
"eyes": eyes,
|
|
"softcore_outfit": softcore_outfit,
|
|
"hardcore_clothing": hardcore_clothing,
|
|
}
|
|
for key, value in updates.items():
|
|
value = str(value or "").strip()
|
|
if value:
|
|
base[key] = value
|
|
result = {"config_type": "character_manual", **base}
|
|
result["summary"] = character_manual_summary(base)
|
|
return json.dumps(result, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str:
|
|
detail = character_policy.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_value: 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_value,),
|
|
"compact": (body_phrase_value, skin),
|
|
"medium": (body_phrase_value, skin, hair),
|
|
"full": (body_phrase_value, 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 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):
|
|
return row["softcore_row"]
|
|
return row
|
|
|
|
|
|
def character_profile_descriptor(profile: dict[str, Any]) -> str:
|
|
subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip()
|
|
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"),
|
|
profile.get("descriptor_detail"),
|
|
)
|
|
|
|
|
|
def normalize_character_profile(profile: dict[str, Any], profile_name: str = "") -> dict[str, Any]:
|
|
subject_type = str(profile.get("subject_type") or profile.get("primary_subject") or profile.get("subject") or "").strip()
|
|
if subject_type not in ("woman", "man"):
|
|
subject_type = "woman"
|
|
body = str(profile.get("body") or profile.get("body_type") or "").strip()
|
|
figure = str(profile.get("figure") or "").strip()
|
|
normalized_body_phrase = str(profile.get("body_phrase") or "").strip() or body_phrase(body, figure)
|
|
normalized = {
|
|
"profile_type": "character",
|
|
"profile_name": safe_profile_name(profile_name or str(profile.get("profile_name") or "")),
|
|
"subject_type": subject_type,
|
|
"subject": subject_type,
|
|
"subject_phrase": subject_type,
|
|
"age": str(profile.get("age") or profile.get("age_band") or "").strip(),
|
|
"body": body,
|
|
"body_phrase": normalized_body_phrase,
|
|
"skin": str(profile.get("skin") or "").strip(),
|
|
"hair": str(profile.get("hair") or "").strip(),
|
|
"eyes": str(profile.get("eyes") or "").strip(),
|
|
"figure": figure,
|
|
"descriptor_detail": character_policy.normalize_descriptor_detail(profile.get("descriptor_detail")),
|
|
}
|
|
normalized["descriptor"] = character_profile_descriptor(normalized)
|
|
return normalized
|
|
|
|
|
|
def build_character_profile_json(
|
|
profile_name: str = "",
|
|
source: str = "metadata_json",
|
|
metadata_json: str | dict[str, Any] | None = "",
|
|
character_slot_row: dict[str, Any] | None = None,
|
|
subject_type: str = "woman",
|
|
age: str = "",
|
|
body: str = "",
|
|
body_phrase_value: str = "",
|
|
skin: str = "",
|
|
hair: str = "",
|
|
eyes: str = "",
|
|
figure: str = "",
|
|
save_now: bool = False,
|
|
) -> dict[str, str]:
|
|
if source == "character_slot":
|
|
row = character_slot_row or {}
|
|
raw_profile = {
|
|
"profile_name": profile_name,
|
|
"subject_type": row.get("subject_type") or subject_type,
|
|
"age": row.get("age") or age,
|
|
"body": row.get("body") or body,
|
|
"body_phrase": row.get("body_phrase") or body_phrase_value,
|
|
"skin": row.get("skin") or skin,
|
|
"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",
|
|
}
|
|
elif source == "metadata_json":
|
|
row = row_from_profile_metadata(metadata_json)
|
|
raw_profile = {
|
|
"profile_name": profile_name,
|
|
"subject_type": row.get("subject_type") or row.get("primary_subject") or subject_type,
|
|
"age": row.get("age") or row.get("age_band") or age,
|
|
"body": row.get("body") or row.get("body_type") or body,
|
|
"body_phrase": row.get("body_phrase") or body_phrase_value,
|
|
"skin": row.get("skin") or skin,
|
|
"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 = {
|
|
"profile_name": profile_name,
|
|
"subject_type": subject_type,
|
|
"age": age,
|
|
"body": body,
|
|
"body_phrase": body_phrase_value,
|
|
"skin": skin,
|
|
"hair": hair,
|
|
"eyes": eyes,
|
|
"figure": figure,
|
|
"descriptor_detail": "auto",
|
|
}
|
|
profile = normalize_character_profile(raw_profile, profile_name)
|
|
saved_path = ""
|
|
status = "not_saved"
|
|
if save_now:
|
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = profile_path(profile["profile_name"])
|
|
path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
saved_path = str(path)
|
|
status = "saved"
|
|
return {
|
|
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
|
"profile_name": profile["profile_name"],
|
|
"descriptor": profile["descriptor"],
|
|
"saved_path": saved_path,
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def save_character_profile_payload(profile_name: str = "", profile_json: str | dict[str, Any] | None = "") -> dict[str, str]:
|
|
raw_profile = load_json_object(profile_json, "profile_json")
|
|
if not raw_profile:
|
|
raise ValueError("No cached character profile is available to save.")
|
|
profile = normalize_character_profile(raw_profile, profile_name or str(raw_profile.get("profile_name") or ""))
|
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = profile_path(profile["profile_name"])
|
|
path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
return {
|
|
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
|
"profile_name": profile["profile_name"],
|
|
"descriptor": profile["descriptor"],
|
|
"saved_path": str(path),
|
|
"status": "saved",
|
|
}
|
|
|
|
|
|
def empty_profile_result(status: str = "empty") -> dict[str, str]:
|
|
return {
|
|
"profile_json": "",
|
|
"profile_name": "",
|
|
"descriptor": "",
|
|
"saved_path": "",
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def apply_character_profile_overrides(
|
|
profile: dict[str, Any],
|
|
override_subject_type: str = "",
|
|
override_age: str = "",
|
|
override_body: str = "",
|
|
override_body_phrase: str = "",
|
|
override_skin: str = "",
|
|
override_hair: str = "",
|
|
override_eyes: str = "",
|
|
override_figure: str = "",
|
|
override_descriptor_detail: str = "",
|
|
) -> dict[str, Any]:
|
|
updated = dict(profile)
|
|
subject_type = str(override_subject_type or "").strip()
|
|
if subject_type in ("woman", "man"):
|
|
updated["subject_type"] = subject_type
|
|
updated["subject"] = subject_type
|
|
updated["subject_phrase"] = subject_type
|
|
for key, value in (
|
|
("age", override_age),
|
|
("body", override_body),
|
|
("body_phrase", override_body_phrase),
|
|
("skin", override_skin),
|
|
("hair", override_hair),
|
|
("eyes", override_eyes),
|
|
("figure", override_figure),
|
|
):
|
|
text = str(value or "").strip()
|
|
if text:
|
|
updated[key] = text
|
|
descriptor_detail = str(override_descriptor_detail or "").strip()
|
|
if descriptor_detail and descriptor_detail != "keep_profile":
|
|
updated["descriptor_detail"] = character_policy.normalize_descriptor_detail(descriptor_detail)
|
|
if not str(updated.get("body_phrase") or "").strip():
|
|
updated["body_phrase"] = body_phrase(updated.get("body"), updated.get("figure"))
|
|
updated["descriptor"] = character_profile_descriptor(updated)
|
|
return updated
|
|
|
|
|
|
def load_character_profile_json(
|
|
profile_name: str = "",
|
|
fallback_profile_json: str | dict[str, Any] | None = "",
|
|
enabled: bool = True,
|
|
delete_now: bool = False,
|
|
rename_now: bool = False,
|
|
rename_to: str = "",
|
|
override_subject_type: str = "",
|
|
override_age: str = "",
|
|
override_body: str = "",
|
|
override_body_phrase: str = "",
|
|
override_skin: str = "",
|
|
override_hair: str = "",
|
|
override_eyes: str = "",
|
|
override_figure: str = "",
|
|
override_descriptor_detail: str = "",
|
|
) -> dict[str, str]:
|
|
if not enabled:
|
|
return empty_profile_result("disabled")
|
|
if delete_now and rename_now:
|
|
return empty_profile_result("choose_delete_or_rename")
|
|
|
|
raw_profile = load_json_object(fallback_profile_json, "fallback_profile_json")
|
|
saved_path = ""
|
|
if profile_name and profile_name != "manual":
|
|
path = profile_path(profile_name)
|
|
if delete_now:
|
|
if path.exists():
|
|
path.unlink()
|
|
return empty_profile_result(f"deleted:{path.stem}")
|
|
return empty_profile_result(f"delete_missing:{safe_profile_name(profile_name)}")
|
|
if rename_now:
|
|
new_name = safe_profile_name(rename_to)
|
|
if not rename_to.strip():
|
|
return empty_profile_result("rename_missing_name")
|
|
if not path.exists():
|
|
return empty_profile_result(f"rename_missing:{safe_profile_name(profile_name)}")
|
|
target = profile_path(new_name)
|
|
if target.exists() and target != path:
|
|
return empty_profile_result(f"rename_target_exists:{target.stem}")
|
|
raw_profile = load_json_object(path.read_text(encoding="utf-8"), "character_profile")
|
|
profile = normalize_character_profile(raw_profile, new_name)
|
|
target.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
if target != path:
|
|
path.unlink()
|
|
return {
|
|
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
|
"profile_name": profile["profile_name"],
|
|
"descriptor": profile["descriptor"],
|
|
"saved_path": str(target),
|
|
"status": f"renamed:{path.stem}->{target.stem}",
|
|
}
|
|
if path.exists():
|
|
raw_profile = load_json_object(path.read_text(encoding="utf-8"), "character_profile")
|
|
saved_path = str(path)
|
|
if not raw_profile:
|
|
return empty_profile_result("empty")
|
|
profile = normalize_character_profile(raw_profile, profile_name or raw_profile.get("profile_name", ""))
|
|
profile = apply_character_profile_overrides(
|
|
profile,
|
|
override_subject_type=override_subject_type,
|
|
override_age=override_age,
|
|
override_body=override_body,
|
|
override_body_phrase=override_body_phrase,
|
|
override_skin=override_skin,
|
|
override_hair=override_hair,
|
|
override_eyes=override_eyes,
|
|
override_figure=override_figure,
|
|
override_descriptor_detail=override_descriptor_detail,
|
|
)
|
|
return {
|
|
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
|
"profile_name": profile["profile_name"],
|
|
"descriptor": profile["descriptor"],
|
|
"saved_path": saved_path,
|
|
"status": "loaded" if saved_path else "fallback",
|
|
}
|
|
|
|
|
|
def parse_character_profile(character_profile: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
raw = load_json_object(character_profile, "character_profile")
|
|
if not raw:
|
|
return {}
|
|
if raw.get("profile_type") == "character" or any(key in raw for key in ("age", "age_band", "skin", "hair", "eyes")):
|
|
return normalize_character_profile(raw, str(raw.get("profile_name") or ""))
|
|
return {}
|
|
|
|
|
|
def apply_character_profile_to_context(
|
|
context: dict[str, Any],
|
|
character_profile: str | dict[str, Any] | None,
|
|
) -> tuple[dict[str, Any], dict[str, Any], str]:
|
|
profile = parse_character_profile(character_profile)
|
|
if not profile:
|
|
return context, {}, "none"
|
|
if context.get("subject_type") not in ("woman", "man"):
|
|
return context, profile, "skipped_non_single_subject"
|
|
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",
|
|
"descriptor_detail",
|
|
):
|
|
value = profile.get(key)
|
|
if value:
|
|
updated[key] = value
|
|
updated["subject"] = profile["subject_type"]
|
|
updated["subject_phrase"] = profile["subject_type"]
|
|
return updated, profile, "applied"
|
|
|
|
|
|
_body_phrase = body_phrase
|
|
_safe_profile_name = safe_profile_name
|
|
_profile_path = profile_path
|
|
_load_json_object = load_json_object
|
|
_parse_character_manual_config = parse_character_manual_config
|
|
_character_manual_summary = character_manual_summary
|
|
_descriptor_detail_for_subject = descriptor_detail_for_subject
|
|
_descriptor_from_parts = descriptor_from_parts
|
|
_row_from_profile_metadata = row_from_profile_metadata
|
|
_character_profile_descriptor = character_profile_descriptor
|
|
_normalize_character_profile = normalize_character_profile
|
|
_empty_profile_result = empty_profile_result
|
|
_apply_character_profile_overrides = apply_character_profile_overrides
|
|
_parse_character_profile = parse_character_profile
|
|
_apply_character_profile_to_context = apply_character_profile_to_context
|