diff --git a/character_profile.py b/character_profile.py new file mode 100644 index 0000000..5028f52 --- /dev/null +++ b/character_profile.py @@ -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 diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 70d9f58..857b4e1 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -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. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index be0c791..39e76b9 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -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 diff --git a/node_character.py b/node_character.py index 3c16f5f..6470f2b 100644 --- a/node_character.py +++ b/node_character.py @@ -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, ) diff --git a/prompt_builder.py b/prompt_builder.py index b63c88e..0613ac0 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -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: diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index cc0e3af..e210200 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -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),