Files
ComfyUI-Ethanfel-Prompt-Bui…/character_profile.py

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