From 65574222b2541b3884e4ed761077d852ef09c299 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 00:27:57 +0200 Subject: [PATCH] Extract generation profile config policy --- docs/prompt-architecture-improvement-plan.md | 3 + docs/prompt-pool-routing-map.md | 1 + generation_profile_config.py | 165 +++++++++++++++++++ node_profile_filter.py | 14 +- prompt_builder.py | 137 ++------------- tools/prompt_smoke.py | 43 +++++ 6 files changed, 238 insertions(+), 125 deletions(-) create mode 100644 generation_profile_config.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 49bd6c0..4f79bb9 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -107,6 +107,9 @@ Already isolated: - category/cast route preset schemas, config JSON builders, choice lists, and parsers live in `category_cast_config.py`; `prompt_builder.py` keeps public delegate wrappers for existing nodes and tests. +- generation profile presets, override normalization, trigger policy, and + profile config parsing live in `generation_profile_config.py`; + `prompt_builder.py` keeps public delegate wrappers. - location/composition config presets, themed location packs, custom location/composition entry parsing, merge behavior, and config parsing live in `location_config.py`; `prompt_builder.py` still applies selected configs diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index af57165..d0f3e0a 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -70,6 +70,7 @@ Core helper ownership: | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | | `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. | +| `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. | | `location_config.py` | Location/composition preset schemas, themed location packs, custom location/composition parsing, pool merge behavior, and location/composition config parsing. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, and hardcore cast count policy. | diff --git a/generation_profile_config.py b/generation_profile_config.py new file mode 100644 index 0000000..c628026 --- /dev/null +++ b/generation_profile_config.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import json +from typing import Any + + +GENERATION_PROFILE_PRESETS = { + "balanced": { + "clothing": "full", + "poses": "standard", + "expression_enabled": True, + "expression_intensity": 0.5, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "casual_clean": { + "clothing": "full", + "poses": "standard", + "expression_enabled": True, + "expression_intensity": 0.35, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "evocative_softcore": { + "clothing": "minimal", + "poses": "evocative", + "expression_enabled": True, + "expression_intensity": 0.65, + "backside_bias": 0.2, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "hardcore_intense": { + "clothing": "minimal", + "poses": "evocative", + "expression_enabled": True, + "expression_intensity": 0.9, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "krea2_friendly": { + "clothing": "full", + "poses": "standard", + "expression_enabled": True, + "expression_intensity": 0.55, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": False, + }, + "flux_original": { + "clothing": "full", + "poses": "standard", + "expression_enabled": True, + "expression_intensity": 0.5, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, +} + + +def _is_false(value: Any) -> bool: + if isinstance(value, bool): + return value is False + if isinstance(value, str): + return value.strip().lower() in ("false", "0", "no", "off") + return False + + +def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return default + return max(min_value, min(max_value, number)) + + +def generation_profile_choices() -> list[str]: + return list(GENERATION_PROFILE_PRESETS) + + +def build_generation_profile_json( + profile: str = "balanced", + clothing_override: str = "profile_default", + poses_override: str = "profile_default", + expression_intensity_mode: str = "profile_default", + expression_intensity: float = -1.0, + backside_bias: float = -1.0, + minimal_clothing_ratio: float = -1.0, + standard_pose_ratio: float = -1.0, + trigger_policy: str = "profile_default", + expression_enabled: bool = True, +) -> str: + profile = profile if profile in GENERATION_PROFILE_PRESETS else "balanced" + config = dict(GENERATION_PROFILE_PRESETS[profile]) + if clothing_override in ("full", "minimal", "random"): + config["clothing"] = clothing_override + if poses_override in ("standard", "evocative", "random"): + config["poses"] = poses_override + config["expression_enabled"] = not _is_false(expression_enabled) + if expression_intensity_mode == "random": + config["expression_intensity"] = -1.0 + elif expression_intensity_mode == "fixed" and float(expression_intensity) >= 0: + config["expression_intensity"] = _clamped_float(expression_intensity, config["expression_intensity"]) + if float(backside_bias) >= 0: + config["backside_bias"] = _clamped_float(backside_bias, config["backside_bias"]) + if float(minimal_clothing_ratio) >= 0: + config["minimal_clothing_ratio"] = _clamped_float(minimal_clothing_ratio, config["minimal_clothing_ratio"]) + if float(standard_pose_ratio) >= 0: + config["standard_pose_ratio"] = _clamped_float(standard_pose_ratio, config["standard_pose_ratio"]) + if trigger_policy == "prepend_trigger": + config["prepend_trigger_to_prompt"] = True + elif trigger_policy == "do_not_prepend": + config["prepend_trigger_to_prompt"] = False + config["profile"] = profile + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + +def parse_generation_profile(profile_config: str | dict[str, Any] | None) -> dict[str, Any]: + if not profile_config: + return dict(GENERATION_PROFILE_PRESETS["balanced"]) + if isinstance(profile_config, dict): + raw = profile_config + else: + try: + raw = json.loads(str(profile_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid generation_profile JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("generation_profile must be a JSON object") + profile = str(raw.get("profile") or "balanced") + parsed = dict(GENERATION_PROFILE_PRESETS.get(profile, GENERATION_PROFILE_PRESETS["balanced"])) + parsed.update(raw) + parsed["clothing"] = parsed["clothing"] if parsed.get("clothing") in ("full", "minimal", "random") else "full" + parsed["poses"] = parsed["poses"] if parsed.get("poses") in ("standard", "evocative", "random") else "standard" + parsed["expression_enabled"] = not _is_false(parsed.get("expression_enabled", True)) + try: + raw_expression_intensity = float(parsed.get("expression_intensity")) + except (TypeError, ValueError): + raw_expression_intensity = 0.5 + parsed["expression_intensity"] = -1.0 if raw_expression_intensity < 0 else _clamped_float(raw_expression_intensity, 0.5) + parsed["backside_bias"] = _clamped_float(parsed.get("backside_bias"), 0.0) + parsed["minimal_clothing_ratio"] = _clamped_float(parsed.get("minimal_clothing_ratio"), -1.0, -1.0, 1.0) + parsed["standard_pose_ratio"] = _clamped_float(parsed.get("standard_pose_ratio"), -1.0, -1.0, 1.0) + parsed["trigger"] = str(parsed.get("trigger") or "sxcpinup_coloredpencil") + parsed["prepend_trigger_to_prompt"] = bool(parsed.get("prepend_trigger_to_prompt")) + return parsed + + +_parse_generation_profile = parse_generation_profile diff --git a/node_profile_filter.py b/node_profile_filter.py index af92af0..2508695 100644 --- a/node_profile_filter.py +++ b/node_profile_filter.py @@ -3,18 +3,22 @@ from __future__ import annotations import json try: - from .prompt_builder import ( - build_ethnicity_list_json, - build_filter_config_json, + from .generation_profile_config import ( build_generation_profile_json, generation_profile_choices, ) + from .prompt_builder import ( + build_ethnicity_list_json, + build_filter_config_json, + ) except ImportError: # Allows local smoke tests from the repository root. + from generation_profile_config import ( + build_generation_profile_json, + generation_profile_choices, + ) from prompt_builder import ( build_ethnicity_list_json, build_filter_config_json, - build_generation_profile_json, - generation_profile_choices, ) diff --git a/prompt_builder.py b/prompt_builder.py index c6d2824..350d9e5 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -26,6 +26,7 @@ try: from . import camera_config as camera_policy from . import category_cast_config as category_cast_policy from . import generate_prompt_batches as g + from . import generation_profile_config as generation_profile_policy from . import location_config as location_policy from . import pair_clothing from . import pair_camera @@ -65,6 +66,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import camera_config as camera_policy import category_cast_config as category_cast_policy import generate_prompt_batches as g + import generation_profile_config as generation_profile_policy import location_config as location_policy import pair_clothing import pair_camera @@ -1035,74 +1037,7 @@ def seed_mode_choices() -> list[str]: CATEGORY_PRESETS = category_cast_policy.CATEGORY_PRESETS CAST_PRESETS = category_cast_policy.CAST_PRESETS -GENERATION_PROFILE_PRESETS = { - "balanced": { - "clothing": "full", - "poses": "standard", - "expression_enabled": True, - "expression_intensity": 0.5, - "backside_bias": 0.0, - "minimal_clothing_ratio": -1.0, - "standard_pose_ratio": -1.0, - "trigger": "sxcpinup_coloredpencil", - "prepend_trigger_to_prompt": True, - }, - "casual_clean": { - "clothing": "full", - "poses": "standard", - "expression_enabled": True, - "expression_intensity": 0.35, - "backside_bias": 0.0, - "minimal_clothing_ratio": -1.0, - "standard_pose_ratio": -1.0, - "trigger": "sxcpinup_coloredpencil", - "prepend_trigger_to_prompt": True, - }, - "evocative_softcore": { - "clothing": "minimal", - "poses": "evocative", - "expression_enabled": True, - "expression_intensity": 0.65, - "backside_bias": 0.2, - "minimal_clothing_ratio": -1.0, - "standard_pose_ratio": -1.0, - "trigger": "sxcpinup_coloredpencil", - "prepend_trigger_to_prompt": True, - }, - "hardcore_intense": { - "clothing": "minimal", - "poses": "evocative", - "expression_enabled": True, - "expression_intensity": 0.9, - "backside_bias": 0.0, - "minimal_clothing_ratio": -1.0, - "standard_pose_ratio": -1.0, - "trigger": "sxcpinup_coloredpencil", - "prepend_trigger_to_prompt": True, - }, - "krea2_friendly": { - "clothing": "full", - "poses": "standard", - "expression_enabled": True, - "expression_intensity": 0.55, - "backside_bias": 0.0, - "minimal_clothing_ratio": -1.0, - "standard_pose_ratio": -1.0, - "trigger": "sxcpinup_coloredpencil", - "prepend_trigger_to_prompt": False, - }, - "flux_original": { - "clothing": "full", - "poses": "standard", - "expression_enabled": True, - "expression_intensity": 0.5, - "backside_bias": 0.0, - "minimal_clothing_ratio": -1.0, - "standard_pose_ratio": -1.0, - "trigger": "sxcpinup_coloredpencil", - "prepend_trigger_to_prompt": True, - }, -} +GENERATION_PROFILE_PRESETS = generation_profile_policy.GENERATION_PROFILE_PRESETS def category_preset_choices() -> list[str]: @@ -1114,7 +1049,7 @@ def cast_preset_choices() -> list[str]: def generation_profile_choices() -> list[str]: - return list(GENERATION_PROFILE_PRESETS) + return generation_profile_policy.generation_profile_choices() def build_category_config_json(preset: str = "auto_weighted", subcategory: str = RANDOM_SUBCATEGORY) -> str: @@ -1145,60 +1080,22 @@ def build_generation_profile_json( trigger_policy: str = "profile_default", expression_enabled: bool = True, ) -> str: - profile = profile if profile in GENERATION_PROFILE_PRESETS else "balanced" - config = dict(GENERATION_PROFILE_PRESETS[profile]) - if clothing_override in ("full", "minimal", "random"): - config["clothing"] = clothing_override - if poses_override in ("standard", "evocative", "random"): - config["poses"] = poses_override - config["expression_enabled"] = not _is_false(expression_enabled) - if expression_intensity_mode == "random": - config["expression_intensity"] = -1.0 - elif expression_intensity_mode == "fixed" and float(expression_intensity) >= 0: - config["expression_intensity"] = _clamped_float(expression_intensity, config["expression_intensity"]) - if float(backside_bias) >= 0: - config["backside_bias"] = _clamped_float(backside_bias, config["backside_bias"]) - if float(minimal_clothing_ratio) >= 0: - config["minimal_clothing_ratio"] = _clamped_float(minimal_clothing_ratio, config["minimal_clothing_ratio"]) - if float(standard_pose_ratio) >= 0: - config["standard_pose_ratio"] = _clamped_float(standard_pose_ratio, config["standard_pose_ratio"]) - if trigger_policy == "prepend_trigger": - config["prepend_trigger_to_prompt"] = True - elif trigger_policy == "do_not_prepend": - config["prepend_trigger_to_prompt"] = False - config["profile"] = profile - return json.dumps(config, ensure_ascii=True, sort_keys=True) + return generation_profile_policy.build_generation_profile_json( + profile=profile, + clothing_override=clothing_override, + poses_override=poses_override, + expression_intensity_mode=expression_intensity_mode, + expression_intensity=expression_intensity, + backside_bias=backside_bias, + minimal_clothing_ratio=minimal_clothing_ratio, + standard_pose_ratio=standard_pose_ratio, + trigger_policy=trigger_policy, + expression_enabled=expression_enabled, + ) def _parse_generation_profile(profile_config: str | dict[str, Any] | None) -> dict[str, Any]: - if not profile_config: - return dict(GENERATION_PROFILE_PRESETS["balanced"]) - if isinstance(profile_config, dict): - raw = profile_config - else: - try: - raw = json.loads(str(profile_config)) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid generation_profile JSON: {exc}") from exc - if not isinstance(raw, dict): - raise ValueError("generation_profile must be a JSON object") - profile = str(raw.get("profile") or "balanced") - parsed = dict(GENERATION_PROFILE_PRESETS.get(profile, GENERATION_PROFILE_PRESETS["balanced"])) - parsed.update(raw) - parsed["clothing"] = parsed["clothing"] if parsed.get("clothing") in ("full", "minimal", "random") else "full" - parsed["poses"] = parsed["poses"] if parsed.get("poses") in ("standard", "evocative", "random") else "standard" - parsed["expression_enabled"] = not _is_false(parsed.get("expression_enabled", True)) - try: - raw_expression_intensity = float(parsed.get("expression_intensity")) - except (TypeError, ValueError): - raw_expression_intensity = 0.5 - parsed["expression_intensity"] = -1.0 if raw_expression_intensity < 0 else _clamped_float(raw_expression_intensity, 0.5) - parsed["backside_bias"] = _clamped_float(parsed.get("backside_bias"), 0.0) - parsed["minimal_clothing_ratio"] = _clamped_float(parsed.get("minimal_clothing_ratio"), -1.0, -1.0, 1.0) - parsed["standard_pose_ratio"] = _clamped_float(parsed.get("standard_pose_ratio"), -1.0, -1.0, 1.0) - parsed["trigger"] = str(parsed.get("trigger") or "sxcpinup_coloredpencil") - parsed["prepend_trigger_to_prompt"] = bool(parsed.get("prepend_trigger_to_prompt")) - return parsed + return generation_profile_policy.parse_generation_profile(profile_config) def build_filter_config_json( diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 5151e38..088f18a 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -27,6 +27,7 @@ import caption_naturalizer # noqa: E402 import category_cast_config # noqa: E402 import category_library # noqa: E402 import __init__ as sxcp_nodes # noqa: E402 +import generation_profile_config # noqa: E402 import krea_formatter # noqa: E402 import location_config # noqa: E402 import prompt_builder as pb # noqa: E402 @@ -578,6 +579,47 @@ def smoke_category_cast_config_policy() -> None: _expect((empty_cast.get("women_count"), empty_cast.get("men_count")) == (1, 0), "Empty custom cast was not corrected") +def smoke_generation_profile_config_policy() -> None: + _expect( + pb.GENERATION_PROFILE_PRESETS is generation_profile_config.GENERATION_PROFILE_PRESETS, + "Prompt builder generation profile presets are not delegated", + ) + _expect("krea2_friendly" in generation_profile_config.generation_profile_choices(), "Generation profile choices lost krea2_friendly") + + profile = json.loads( + pb.build_generation_profile_json( + profile="krea2_friendly", + clothing_override="minimal", + poses_override="random", + expression_enabled=False, + expression_intensity_mode="random", + expression_intensity=0.8, + backside_bias=2, + minimal_clothing_ratio=0.25, + standard_pose_ratio=0.75, + trigger_policy="prepend_trigger", + ) + ) + _expect(profile.get("profile") == "krea2_friendly", "Generation profile output lost selected profile") + _expect(profile.get("clothing") == "minimal", "Generation profile clothing override failed") + _expect(profile.get("poses") == "random", "Generation profile poses override failed") + _expect(profile.get("expression_enabled") is False, "Generation profile expression disable failed") + _expect(profile.get("expression_intensity") == -1.0, "Generation profile random expression marker changed") + _expect(profile.get("backside_bias") == 1.0, "Generation profile backside bias clamp changed") + _expect(profile.get("prepend_trigger_to_prompt") is True, "Generation profile trigger override failed") + + parsed = pb._parse_generation_profile(profile) + _expect(parsed.get("clothing") == "minimal", "Generation profile parser wrapper lost clothing") + _expect(parsed.get("expression_enabled") is False, "Generation profile parser wrapper lost expression disable") + _expect(parsed.get("minimal_clothing_ratio") == 0.25, "Generation profile parser wrapper lost minimal clothing ratio") + + fallback = generation_profile_config.parse_generation_profile({"profile": "unknown", "clothing": "bad", "poses": "bad"}) + _expect(fallback.get("profile") == "unknown", "Generation profile parser should preserve raw profile label") + _expect(fallback.get("clothing") == "full", "Generation profile parser did not normalize invalid clothing") + _expect(fallback.get("poses") == "standard", "Generation profile parser did not normalize invalid poses") + _expect(fallback.get("trigger") == "sxcpinup_coloredpencil", "Generation profile parser lost default trigger") + + def smoke_category_library_route() -> None: categories = category_library.load_category_library() _expect(len(categories) >= 3, "category library should load JSON categories") @@ -2486,6 +2528,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("config_route_location_theme", smoke_config_route_location_theme), ("location_config_policy", smoke_location_config_policy), ("category_cast_config_policy", smoke_category_cast_config_policy), + ("generation_profile_config_policy", smoke_generation_profile_config_policy), ("category_library_route", smoke_category_library_route), ("hardcore_category_routes", smoke_hardcore_category_routes), ("krea_close_foreplay_route", smoke_krea_close_foreplay_route),