Extract character profile policy
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
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
|
||||
@@ -113,7 +113,12 @@ Already isolated:
|
||||
- character choice lists, descriptor detail/presence/slot-seed normalization,
|
||||
characteristic-list JSON builders/parsers, eye labels, hair config
|
||||
builders/parsers, and hair phrase helpers live in `character_config.py`;
|
||||
`prompt_builder.py` still resolves full character slots and profiles.
|
||||
`prompt_builder.py` still resolves full character slots.
|
||||
- character manual-detail config, profile name/path policy, profile JSON
|
||||
normalization, descriptor assembly, save/load/rename/delete operations,
|
||||
fallback profile loading, and context override application live in
|
||||
`character_profile.py`; `prompt_builder.py` only bridges generated slot rows
|
||||
into profile saves.
|
||||
- generation profile presets, override normalization, trigger policy, and
|
||||
profile config parsing live in `generation_profile_config.py`;
|
||||
`prompt_builder.py` keeps public delegate wrappers.
|
||||
|
||||
@@ -71,6 +71,7 @@ Core helper ownership:
|
||||
| `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. |
|
||||
| `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. |
|
||||
| `character_config.py` | Character choice lists, descriptor detail/presence/slot-seed normalization, characteristic-list JSON builders/parsers, eye labels, hair config builders/parsers, and hair phrase helpers. |
|
||||
| `character_profile.py` | Character manual-detail config, profile name/path policy, profile JSON normalization, descriptor assembly, save/load/rename/delete operations, fallback profile loading, and context override application. |
|
||||
| `filter_config.py` | Ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter parsing, and ethnicity normalization used by builder and character routes. |
|
||||
| `generation_profile_config.py` | Generation profile presets, profile option overrides, trigger policy, expression/pose/clothing config normalization, and profile config parsing. |
|
||||
| `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. |
|
||||
@@ -127,7 +128,7 @@ These recipes identify the intended road before editing prompt text.
|
||||
| Use Qwen/orbit camera geometry | Qwen/orbit node -> camera_config -> builder/pair | For pair, use `softcore_camera_config` and/or `hardcore_camera_config`; set mode from config in options | `_camera_config_with_mode`, `_camera_directive`, `_camera_scene_directive_for_context` |
|
||||
| Use Krea2 for only hard prompt from a pair | Pair `metadata_json` -> Krea2 Formatter | `target=hardcore`, `input_hint=metadata_json` or auto with metadata connected | `_insta_pair_to_krea`, hard row fields |
|
||||
| Convert builder output to SDXL tags | Builder/pair metadata -> SDXL Formatter | Use metadata input; set `target`; select style and quality preset | `_row_core_tags`, `_soft_tags`, `_hard_tags` |
|
||||
| Save/reuse character | Slot/profile nodes -> Profile Save/Load -> slot/builder | Save from the row/profile data you want, not a freshly randomized disconnected route | profile helpers, `web/profile_buttons.js`, profile JSON |
|
||||
| Save/reuse character | Slot/profile nodes -> Profile Save/Load -> slot/builder | Save from the row/profile data you want, not a freshly randomized disconnected route | `character_profile.py`, `web/profile_buttons.js`, profile JSON |
|
||||
|
||||
## Seed Axes
|
||||
|
||||
@@ -370,10 +371,10 @@ Edit targets:
|
||||
- Appearance field generation: `_context_from_character_slot`,
|
||||
`_character_context_for_label`, `_cast_descriptor_entries`.
|
||||
- Profile save/load: `SxCPCharacterProfileSave`,
|
||||
`SxCPCharacterProfileLoad`, profile helpers in `prompt_builder.py`, and
|
||||
`SxCPCharacterProfileLoad`, profile policy in `character_profile.py`, and
|
||||
`web/profile_buttons.js`.
|
||||
- Hair/body/ethnicity list behavior: characteristic config builders in
|
||||
`prompt_builder.py`.
|
||||
`character_config.py` and ethnicity filters in `filter_config.py`.
|
||||
|
||||
## Insta/OF Pair Route
|
||||
|
||||
|
||||
+10
-6
@@ -18,18 +18,20 @@ try:
|
||||
character_presence_choices,
|
||||
character_woman_body_choices,
|
||||
)
|
||||
from .prompt_builder import (
|
||||
from .character_profile import (
|
||||
build_character_manual_config_json,
|
||||
character_profile_choices,
|
||||
load_character_profile_json,
|
||||
)
|
||||
from .prompt_builder import (
|
||||
build_character_profile_json,
|
||||
build_character_slot_json,
|
||||
character_ethnicity_choices,
|
||||
character_figure_choices,
|
||||
character_hardcore_clothing_state_choices,
|
||||
character_hardcore_clothing_values,
|
||||
character_profile_choices,
|
||||
character_softcore_outfit_source_choices,
|
||||
character_softcore_outfit_values,
|
||||
load_character_profile_json,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from character_config import (
|
||||
@@ -47,18 +49,20 @@ except ImportError: # Allows local smoke tests from the repository root.
|
||||
character_presence_choices,
|
||||
character_woman_body_choices,
|
||||
)
|
||||
from prompt_builder import (
|
||||
from character_profile import (
|
||||
build_character_manual_config_json,
|
||||
character_profile_choices,
|
||||
load_character_profile_json,
|
||||
)
|
||||
from prompt_builder import (
|
||||
build_character_profile_json,
|
||||
build_character_slot_json,
|
||||
character_ethnicity_choices,
|
||||
character_figure_choices,
|
||||
character_hardcore_clothing_state_choices,
|
||||
character_hardcore_clothing_values,
|
||||
character_profile_choices,
|
||||
character_softcore_outfit_source_choices,
|
||||
character_softcore_outfit_values,
|
||||
load_character_profile_json,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+75
-316
@@ -25,6 +25,7 @@ try:
|
||||
)
|
||||
from . import camera_config as camera_policy
|
||||
from . import character_config as character_policy
|
||||
from . import character_profile as character_profile_policy
|
||||
from . import category_cast_config as category_cast_policy
|
||||
from . import filter_config as filter_policy
|
||||
from . import generate_prompt_batches as g
|
||||
@@ -68,6 +69,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
|
||||
)
|
||||
import camera_config as camera_policy
|
||||
import character_config as character_policy
|
||||
import character_profile as character_profile_policy
|
||||
import category_cast_config as category_cast_policy
|
||||
import filter_config as filter_policy
|
||||
import generate_prompt_batches as g
|
||||
@@ -96,7 +98,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = ROOT_DIR / "profiles"
|
||||
PROFILE_DIR = character_profile_policy.PROFILE_DIR
|
||||
|
||||
BUILTIN_CATEGORIES = [
|
||||
"auto_weighted",
|
||||
@@ -1933,81 +1935,34 @@ def _auto_full_choice(seed_config: dict[str, int], seed: int, row_number: int) -
|
||||
|
||||
|
||||
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}"
|
||||
return character_profile_policy.body_phrase(body, 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"
|
||||
return character_profile_policy.safe_profile_name(profile_name)
|
||||
|
||||
|
||||
def _profile_path(profile_name: str) -> Path:
|
||||
return PROFILE_DIR / f"{_safe_profile_name(profile_name)}.json"
|
||||
return character_profile_policy.profile_path(profile_name)
|
||||
|
||||
|
||||
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
|
||||
return character_profile_policy.character_profile_choices()
|
||||
|
||||
|
||||
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
|
||||
return character_profile_policy.load_json_object(value, label)
|
||||
|
||||
|
||||
CHARACTER_MANUAL_FIELDS = (
|
||||
"manual_age",
|
||||
"manual_body",
|
||||
"body_phrase",
|
||||
"skin",
|
||||
"hair",
|
||||
"eyes",
|
||||
"softcore_outfit",
|
||||
"hardcore_clothing",
|
||||
)
|
||||
CHARACTER_MANUAL_FIELDS = character_profile_policy.CHARACTER_MANUAL_FIELDS
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
return character_profile_policy.parse_character_manual_config(value)
|
||||
|
||||
|
||||
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"
|
||||
return character_profile_policy.character_manual_summary(config)
|
||||
|
||||
|
||||
def build_character_manual_config_json(
|
||||
@@ -2022,24 +1977,18 @@ def build_character_manual_config_json(
|
||||
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)
|
||||
return character_profile_policy.build_character_manual_config_json(
|
||||
manual=manual,
|
||||
combine_mode=combine_mode,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _slot_value(value: Any) -> str:
|
||||
@@ -2363,10 +2312,7 @@ def _sanitize_character_expression_text_for_action(
|
||||
|
||||
|
||||
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"
|
||||
return character_profile_policy.descriptor_detail_for_subject(subject, descriptor_detail)
|
||||
|
||||
|
||||
def _descriptor_from_parts(
|
||||
@@ -2378,21 +2324,15 @@ def _descriptor_from_parts(
|
||||
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())
|
||||
return character_profile_policy.descriptor_from_parts(
|
||||
subject,
|
||||
age,
|
||||
body_phrase,
|
||||
skin,
|
||||
hair,
|
||||
eyes,
|
||||
descriptor_detail,
|
||||
)
|
||||
|
||||
|
||||
def _slot_manual_or_choice(choice: str, manual_value: str) -> str:
|
||||
@@ -3070,10 +3010,7 @@ def _cast_descriptor_entries(
|
||||
|
||||
|
||||
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
|
||||
return character_profile_policy.row_from_profile_metadata(metadata_json)
|
||||
|
||||
|
||||
def _row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
@@ -3096,42 +3033,11 @@ def _row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dic
|
||||
|
||||
|
||||
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"),
|
||||
)
|
||||
return character_profile_policy.character_profile_descriptor(profile)
|
||||
|
||||
|
||||
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()
|
||||
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": 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": _normalize_descriptor_detail(profile.get("descriptor_detail")),
|
||||
}
|
||||
normalized["descriptor"] = _character_profile_descriptor(normalized)
|
||||
return normalized
|
||||
return character_profile_policy.normalize_character_profile(profile, profile_name)
|
||||
|
||||
|
||||
def build_character_profile_json(
|
||||
@@ -3149,90 +3055,30 @@ def build_character_profile_json(
|
||||
figure: str = "",
|
||||
save_now: bool = False,
|
||||
) -> dict[str, str]:
|
||||
if source == "character_slot":
|
||||
row = _row_from_character_slot(character_slot or metadata_json)
|
||||
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,
|
||||
"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,
|
||||
"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,
|
||||
"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,
|
||||
}
|
||||
character_slot_row = _row_from_character_slot(character_slot or metadata_json) if source == "character_slot" else None
|
||||
return character_profile_policy.build_character_profile_json(
|
||||
profile_name=profile_name,
|
||||
source=source,
|
||||
metadata_json=metadata_json,
|
||||
character_slot_row=character_slot_row,
|
||||
subject_type=subject_type,
|
||||
age=age,
|
||||
body=body,
|
||||
body_phrase_value=body_phrase,
|
||||
skin=skin,
|
||||
hair=hair,
|
||||
eyes=eyes,
|
||||
figure=figure,
|
||||
save_now=save_now,
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
return character_profile_policy.save_character_profile_payload(profile_name, profile_json)
|
||||
|
||||
|
||||
def _empty_profile_result(status: str = "empty") -> dict[str, str]:
|
||||
return {
|
||||
"profile_json": "",
|
||||
"profile_name": "",
|
||||
"descriptor": "",
|
||||
"saved_path": "",
|
||||
"status": status,
|
||||
}
|
||||
return character_profile_policy.empty_profile_result(status)
|
||||
|
||||
|
||||
def _apply_character_profile_overrides(
|
||||
@@ -3247,31 +3093,18 @@ def _apply_character_profile_overrides(
|
||||
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"] = _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
|
||||
return character_profile_policy.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,
|
||||
)
|
||||
|
||||
|
||||
def load_character_profile_json(
|
||||
@@ -3291,49 +3124,13 @@ def load_character_profile_json(
|
||||
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,
|
||||
return character_profile_policy.load_character_profile_json(
|
||||
profile_name=profile_name,
|
||||
fallback_profile_json=fallback_profile_json,
|
||||
enabled=enabled,
|
||||
delete_now=delete_now,
|
||||
rename_now=rename_now,
|
||||
rename_to=rename_to,
|
||||
override_subject_type=override_subject_type,
|
||||
override_age=override_age,
|
||||
override_body=override_body,
|
||||
@@ -3344,55 +3141,17 @@ def load_character_profile_json(
|
||||
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 {}
|
||||
return character_profile_policy.parse_character_profile(character_profile)
|
||||
|
||||
|
||||
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"
|
||||
return character_profile_policy.apply_character_profile_to_context(context, character_profile)
|
||||
|
||||
|
||||
def _composition_prompt(composition: str) -> str:
|
||||
|
||||
@@ -25,6 +25,7 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
import caption_naturalizer # noqa: E402
|
||||
import character_config # noqa: E402
|
||||
import character_profile # noqa: E402
|
||||
import category_cast_config # noqa: E402
|
||||
import category_library # noqa: E402
|
||||
import filter_config # noqa: E402
|
||||
@@ -711,6 +712,64 @@ def smoke_character_config_policy() -> None:
|
||||
_expect(character_config.normalize_slot_seed(0xFFFFFFFF + 99) == 0xFFFFFFFF, "Slot seed clamp changed")
|
||||
|
||||
|
||||
def smoke_character_profile_policy() -> None:
|
||||
_expect(pb.CHARACTER_MANUAL_FIELDS is character_profile.CHARACTER_MANUAL_FIELDS, "Prompt builder manual fields are not delegated")
|
||||
_expect(pb.PROFILE_DIR == character_profile.PROFILE_DIR, "Prompt builder profile dir is not delegated")
|
||||
_expect(pb._body_phrase("curvy", "hourglass figure") == "curvy build and hourglass figure", "Body phrase helper changed")
|
||||
_expect(pb._safe_profile_name("bad name!*") == "bad_name", "Profile name sanitizer changed")
|
||||
|
||||
manual = json.loads(
|
||||
pb.build_character_manual_config_json(
|
||||
combine_mode="merge_nonempty",
|
||||
manual_age="31-year-old adult",
|
||||
body_phrase="custom body",
|
||||
skin="warm skin",
|
||||
softcore_outfit="red dress",
|
||||
)
|
||||
)
|
||||
_expect(manual.get("manual_age") == "31-year-old adult", "Manual config lost age")
|
||||
_expect(manual.get("softcore_outfit") == "red dress", "Manual config lost outfit")
|
||||
_expect("manual_age=31-year-old adult" in manual.get("summary", ""), "Manual config summary changed")
|
||||
|
||||
metadata_row = {
|
||||
"subject_type": "woman",
|
||||
"age": "28-year-old adult",
|
||||
"body": "curvy",
|
||||
"body_phrase": "curvy figure with full hips",
|
||||
"skin": "warm skin",
|
||||
"hair": "long black hair",
|
||||
"eyes": "brown eyes",
|
||||
"figure": "balanced",
|
||||
"descriptor_detail": "medium",
|
||||
}
|
||||
profile_result = character_profile.build_character_profile_json(
|
||||
profile_name="smoke profile",
|
||||
source="metadata_json",
|
||||
metadata_json=metadata_row,
|
||||
)
|
||||
profile = json.loads(profile_result["profile_json"])
|
||||
_expect(profile.get("profile_name") == "smoke_profile", "Profile name normalization changed")
|
||||
_expect(profile.get("age") == "28-year-old adult", "Profile metadata extraction lost age")
|
||||
_expect("long black hair" in profile_result["descriptor"], "Profile descriptor lost hair at medium detail")
|
||||
|
||||
loaded = pb.load_character_profile_json(
|
||||
profile_name="manual",
|
||||
fallback_profile_json=profile_result["profile_json"],
|
||||
override_age="35-year-old adult",
|
||||
override_descriptor_detail="compact",
|
||||
)
|
||||
loaded_profile = json.loads(loaded["profile_json"])
|
||||
_expect(loaded.get("status") == "fallback", "Profile fallback load status changed")
|
||||
_expect(loaded_profile.get("age") == "35-year-old adult", "Profile override age did not apply")
|
||||
_expect(loaded_profile.get("descriptor_detail") == "compact", "Profile override descriptor detail did not apply")
|
||||
|
||||
context = {"subject_type": "woman", "subject": "woman", "subject_phrase": "woman", "age": "21-year-old adult"}
|
||||
applied_context, applied_profile, status = pb._apply_character_profile_to_context(context, loaded_profile)
|
||||
_expect(status == "applied", "Profile context application changed")
|
||||
_expect(applied_context.get("age") == "35-year-old adult", "Profile context application lost age")
|
||||
_expect(applied_profile.get("profile_type") == "character", "Profile context returned wrong profile")
|
||||
|
||||
|
||||
def smoke_hardcore_position_config_policy() -> None:
|
||||
_expect(
|
||||
pb.HARDCORE_POSITION_FAMILY_CHOICES is hardcore_position_config.HARDCORE_POSITION_FAMILY_CHOICES,
|
||||
@@ -2680,6 +2739,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [
|
||||
("generation_profile_config_policy", smoke_generation_profile_config_policy),
|
||||
("filter_config_policy", smoke_filter_config_policy),
|
||||
("character_config_policy", smoke_character_config_policy),
|
||||
("character_profile_policy", smoke_character_profile_policy),
|
||||
("hardcore_position_config_policy", smoke_hardcore_position_config_policy),
|
||||
("category_library_route", smoke_category_library_route),
|
||||
("hardcore_category_routes", smoke_hardcore_category_routes),
|
||||
|
||||
Reference in New Issue
Block a user