Extract character profile policy

This commit is contained in:
2026-06-27 01:07:23 +02:00
parent 6a3f88ef59
commit 2165e9fc16
6 changed files with 635 additions and 326 deletions
+480
View File
@@ -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
+6 -1
View File
@@ -113,7 +113,12 @@ Already isolated:
- character choice lists, descriptor detail/presence/slot-seed normalization, - character choice lists, descriptor detail/presence/slot-seed normalization,
characteristic-list JSON builders/parsers, eye labels, hair config characteristic-list JSON builders/parsers, eye labels, hair config
builders/parsers, and hair phrase helpers live in `character_config.py`; 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 - generation profile presets, override normalization, trigger policy, and
profile config parsing live in `generation_profile_config.py`; profile config parsing live in `generation_profile_config.py`;
`prompt_builder.py` keeps public delegate wrappers. `prompt_builder.py` keeps public delegate wrappers.
+4 -3
View File
@@ -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. | | `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. | | `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_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. | | `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. | | `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. | | `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 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 | | 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` | | 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 ## Seed Axes
@@ -370,10 +371,10 @@ Edit targets:
- Appearance field generation: `_context_from_character_slot`, - Appearance field generation: `_context_from_character_slot`,
`_character_context_for_label`, `_cast_descriptor_entries`. `_character_context_for_label`, `_cast_descriptor_entries`.
- Profile save/load: `SxCPCharacterProfileSave`, - Profile save/load: `SxCPCharacterProfileSave`,
`SxCPCharacterProfileLoad`, profile helpers in `prompt_builder.py`, and `SxCPCharacterProfileLoad`, profile policy in `character_profile.py`, and
`web/profile_buttons.js`. `web/profile_buttons.js`.
- Hair/body/ethnicity list behavior: characteristic config builders in - 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 ## Insta/OF Pair Route
+10 -6
View File
@@ -18,18 +18,20 @@ try:
character_presence_choices, character_presence_choices,
character_woman_body_choices, character_woman_body_choices,
) )
from .prompt_builder import ( from .character_profile import (
build_character_manual_config_json, build_character_manual_config_json,
character_profile_choices,
load_character_profile_json,
)
from .prompt_builder import (
build_character_profile_json, build_character_profile_json,
build_character_slot_json, build_character_slot_json,
character_ethnicity_choices, character_ethnicity_choices,
character_figure_choices, character_figure_choices,
character_hardcore_clothing_state_choices, character_hardcore_clothing_state_choices,
character_hardcore_clothing_values, character_hardcore_clothing_values,
character_profile_choices,
character_softcore_outfit_source_choices, character_softcore_outfit_source_choices,
character_softcore_outfit_values, character_softcore_outfit_values,
load_character_profile_json,
) )
except ImportError: # Allows local smoke tests from the repository root. except ImportError: # Allows local smoke tests from the repository root.
from character_config import ( from character_config import (
@@ -47,18 +49,20 @@ except ImportError: # Allows local smoke tests from the repository root.
character_presence_choices, character_presence_choices,
character_woman_body_choices, character_woman_body_choices,
) )
from prompt_builder import ( from character_profile import (
build_character_manual_config_json, build_character_manual_config_json,
character_profile_choices,
load_character_profile_json,
)
from prompt_builder import (
build_character_profile_json, build_character_profile_json,
build_character_slot_json, build_character_slot_json,
character_ethnicity_choices, character_ethnicity_choices,
character_figure_choices, character_figure_choices,
character_hardcore_clothing_state_choices, character_hardcore_clothing_state_choices,
character_hardcore_clothing_values, character_hardcore_clothing_values,
character_profile_choices,
character_softcore_outfit_source_choices, character_softcore_outfit_source_choices,
character_softcore_outfit_values, character_softcore_outfit_values,
load_character_profile_json,
) )
+75 -316
View File
@@ -25,6 +25,7 @@ try:
) )
from . import camera_config as camera_policy from . import camera_config as camera_policy
from . import character_config as character_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 category_cast_config as category_cast_policy
from . import filter_config as filter_policy from . import filter_config as filter_policy
from . import generate_prompt_batches as g 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 camera_config as camera_policy
import character_config as character_policy import character_config as character_policy
import character_profile as character_profile_policy
import category_cast_config as category_cast_policy import category_cast_config as category_cast_policy
import filter_config as filter_policy import filter_config as filter_policy
import generate_prompt_batches as g 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 ROOT_DIR = Path(__file__).resolve().parent
PROFILE_DIR = ROOT_DIR / "profiles" PROFILE_DIR = character_profile_policy.PROFILE_DIR
BUILTIN_CATEGORIES = [ BUILTIN_CATEGORIES = [
"auto_weighted", "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: def _body_phrase(body: Any, figure_note: Any = "") -> str:
body = str(body or "").strip() return character_profile_policy.body_phrase(body, figure_note)
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: def _safe_profile_name(profile_name: str) -> str:
profile_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(profile_name or "").strip()).strip("_") return character_profile_policy.safe_profile_name(profile_name)
return profile_name[:64] or "profile"
def _profile_path(profile_name: str) -> Path: 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]: def character_profile_choices() -> list[str]:
if not PROFILE_DIR.exists(): return character_profile_policy.character_profile_choices()
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]: def _load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[str, Any]:
if not value: return character_profile_policy.load_json_object(value, label)
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
CHARACTER_MANUAL_FIELDS = ( CHARACTER_MANUAL_FIELDS = character_profile_policy.CHARACTER_MANUAL_FIELDS
"manual_age",
"manual_body",
"body_phrase",
"skin",
"hair",
"eyes",
"softcore_outfit",
"hardcore_clothing",
)
def _parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]: def _parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]:
if not value: return character_profile_policy.parse_character_manual_config(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: def _character_manual_summary(config: dict[str, str]) -> str:
parts = [f"{key}={value}" for key, value in config.items() if value] return character_profile_policy.character_manual_summary(config)
return "; ".join(parts) if parts else "manual unrestricted"
def build_character_manual_config_json( def build_character_manual_config_json(
@@ -2022,24 +1977,18 @@ def build_character_manual_config_json(
softcore_outfit: str = "", softcore_outfit: str = "",
hardcore_clothing: str = "", hardcore_clothing: str = "",
) -> str: ) -> str:
base = {} if combine_mode == "replace_all" else _parse_character_manual_config(manual) return character_profile_policy.build_character_manual_config_json(
updates = { manual=manual,
"manual_age": manual_age, combine_mode=combine_mode,
"manual_body": manual_body, manual_age=manual_age,
"body_phrase": body_phrase, manual_body=manual_body,
"skin": skin, body_phrase=body_phrase,
"hair": hair, skin=skin,
"eyes": eyes, hair=hair,
"softcore_outfit": softcore_outfit, eyes=eyes,
"hardcore_clothing": hardcore_clothing, 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 _slot_value(value: Any) -> str: 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: def _descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str:
detail = _normalize_descriptor_detail(descriptor_detail) return character_profile_policy.descriptor_detail_for_subject(subject, descriptor_detail)
if detail != "auto":
return detail
return "compact" if str(subject or "").strip().lower() == "man" else "full"
def _descriptor_from_parts( def _descriptor_from_parts(
@@ -2378,21 +2324,15 @@ def _descriptor_from_parts(
eyes: Any, eyes: Any,
descriptor_detail: Any = "auto", descriptor_detail: Any = "auto",
) -> str: ) -> str:
subject = str(subject or "person").strip() or "person" return character_profile_policy.descriptor_from_parts(
age_text = " ".join(str(age or "").strip().split()) subject,
age_text = age_text.removesuffix(" adults").removesuffix(" adult").strip() age,
if age_text in ("adult", "adults"): body_phrase,
age_text = "" skin,
subject_phrase = f"{age_text} adult {subject}".strip() if age_text else f"adult {subject}" hair,
detail = _descriptor_detail_for_subject(subject, descriptor_detail) eyes,
detail_map = { descriptor_detail,
"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())
def _slot_manual_or_choice(choice: str, manual_value: str) -> str: 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]: def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]:
row = _load_json_object(metadata_json, "metadata_json") return character_profile_policy.row_from_profile_metadata(metadata_json)
if isinstance(row.get("softcore_row"), dict):
return row["softcore_row"]
return row
def _row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]: 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: def _character_profile_descriptor(profile: dict[str, Any]) -> str:
subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip() return character_profile_policy.character_profile_descriptor(profile)
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]: 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() return character_profile_policy.normalize_character_profile(profile, profile_name)
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
def build_character_profile_json( def build_character_profile_json(
@@ -3149,90 +3055,30 @@ def build_character_profile_json(
figure: str = "", figure: str = "",
save_now: bool = False, save_now: bool = False,
) -> dict[str, str]: ) -> dict[str, str]:
if source == "character_slot": character_slot_row = _row_from_character_slot(character_slot or metadata_json) if source == "character_slot" else None
row = _row_from_character_slot(character_slot or metadata_json) return character_profile_policy.build_character_profile_json(
raw_profile = { profile_name=profile_name,
"profile_name": profile_name, source=source,
"subject_type": row.get("subject_type") or subject_type, metadata_json=metadata_json,
"age": row.get("age") or age, character_slot_row=character_slot_row,
"body": row.get("body") or body, subject_type=subject_type,
"body_phrase": row.get("body_phrase") or body_phrase, age=age,
"skin": row.get("skin") or skin, body=body,
"hair": row.get("hair") or hair, body_phrase_value=body_phrase,
"eyes": row.get("eyes") or eyes, skin=skin,
"figure": row.get("figure") or figure, hair=hair,
"descriptor_detail": row.get("descriptor_detail") or "auto", eyes=eyes,
} figure=figure,
elif source == "metadata_json": save_now=save_now,
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,
}
def save_character_profile_payload(profile_name: str = "", profile_json: str | dict[str, Any] | None = "") -> dict[str, str]: 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") return character_profile_policy.save_character_profile_payload(profile_name, 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]: def _empty_profile_result(status: str = "empty") -> dict[str, str]:
return { return character_profile_policy.empty_profile_result(status)
"profile_json": "",
"profile_name": "",
"descriptor": "",
"saved_path": "",
"status": status,
}
def _apply_character_profile_overrides( def _apply_character_profile_overrides(
@@ -3247,31 +3093,18 @@ def _apply_character_profile_overrides(
override_figure: str = "", override_figure: str = "",
override_descriptor_detail: str = "", override_descriptor_detail: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
updated = dict(profile) return character_profile_policy.apply_character_profile_overrides(
subject_type = str(override_subject_type or "").strip() profile,
if subject_type in ("woman", "man"): override_subject_type=override_subject_type,
updated["subject_type"] = subject_type override_age=override_age,
updated["subject"] = subject_type override_body=override_body,
updated["subject_phrase"] = subject_type override_body_phrase=override_body_phrase,
for key, value in ( override_skin=override_skin,
("age", override_age), override_hair=override_hair,
("body", override_body), override_eyes=override_eyes,
("body_phrase", override_body_phrase), override_figure=override_figure,
("skin", override_skin), override_descriptor_detail=override_descriptor_detail,
("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
def load_character_profile_json( def load_character_profile_json(
@@ -3291,49 +3124,13 @@ def load_character_profile_json(
override_figure: str = "", override_figure: str = "",
override_descriptor_detail: str = "", override_descriptor_detail: str = "",
) -> dict[str, str]: ) -> dict[str, str]:
if not enabled: return character_profile_policy.load_character_profile_json(
return _empty_profile_result("disabled") profile_name=profile_name,
if delete_now and rename_now: fallback_profile_json=fallback_profile_json,
return _empty_profile_result("choose_delete_or_rename") enabled=enabled,
delete_now=delete_now,
raw_profile = _load_json_object(fallback_profile_json, "fallback_profile_json") rename_now=rename_now,
saved_path = "" rename_to=rename_to,
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_subject_type=override_subject_type,
override_age=override_age, override_age=override_age,
override_body=override_body, override_body=override_body,
@@ -3344,55 +3141,17 @@ def load_character_profile_json(
override_figure=override_figure, override_figure=override_figure,
override_descriptor_detail=override_descriptor_detail, 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]: def _parse_character_profile(character_profile: str | dict[str, Any] | None) -> dict[str, Any]:
raw = _load_json_object(character_profile, "character_profile") return character_profile_policy.parse_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( def _apply_character_profile_to_context(
context: dict[str, Any], context: dict[str, Any],
character_profile: str | dict[str, Any] | None, character_profile: str | dict[str, Any] | None,
) -> tuple[dict[str, Any], dict[str, Any], str]: ) -> tuple[dict[str, Any], dict[str, Any], str]:
profile = _parse_character_profile(character_profile) return character_profile_policy.apply_character_profile_to_context(context, 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"
def _composition_prompt(composition: str) -> str: def _composition_prompt(composition: str) -> str:
+60
View File
@@ -25,6 +25,7 @@ if str(ROOT) not in sys.path:
import caption_naturalizer # noqa: E402 import caption_naturalizer # noqa: E402
import character_config # noqa: E402 import character_config # noqa: E402
import character_profile # noqa: E402
import category_cast_config # noqa: E402 import category_cast_config # noqa: E402
import category_library # noqa: E402 import category_library # noqa: E402
import filter_config # 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") _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: def smoke_hardcore_position_config_policy() -> None:
_expect( _expect(
pb.HARDCORE_POSITION_FAMILY_CHOICES is hardcore_position_config.HARDCORE_POSITION_FAMILY_CHOICES, 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), ("generation_profile_config_policy", smoke_generation_profile_config_policy),
("filter_config_policy", smoke_filter_config_policy), ("filter_config_policy", smoke_filter_config_policy),
("character_config_policy", smoke_character_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), ("hardcore_position_config_policy", smoke_hardcore_position_config_policy),
("category_library_route", smoke_category_library_route), ("category_library_route", smoke_category_library_route),
("hardcore_category_routes", smoke_hardcore_category_routes), ("hardcore_category_routes", smoke_hardcore_category_routes),