diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index f53c2a6..ec8d84b 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -191,6 +191,10 @@ Already isolated: runtime location/composition pool overrides, and generator fallback pool selection live in `row_pools.py`; `prompt_builder.py` keeps public delegate wrappers. +- row scene/pose/expression/composition axis selection, compatible-entry + filtering, expression-disabled handling, per-character expression promotion, + POV composition adaptation, and pose-category environment sanitizing live in + `row_prompt_axes.py`; `prompt_builder.py` keeps a public delegate wrapper. - row expression text cleanup, expression intensity weighting, character-slot/cast expression override resolution, and per-character expression picking plus action-aware character-expression sanitizing live in diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index dc1d9b4..40c9ad4 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -91,6 +91,7 @@ Core helper ownership: | `row_location.py` | Built-in row location/composition config application, deterministic scene/composition choice, source metadata, and legacy prompt/caption rewrites. | | `row_expression.py` | Row expression cleanup, expression intensity weighting, character-slot/cast expression override resolution, per-character expression selection, and action-aware character-expression sanitizing. | | `row_pools.py` | Row scene/expression/pose/composition pool routing, category inheritance handling, runtime location/composition pool overrides, and generator fallback pools. | +| `row_prompt_axes.py` | Row scene/pose/expression/composition axis selection, compatible-entry filtering, expression-disabled handling, per-character expression promotion, POV composition adaptation, and pose-category environment sanitizing. | | `hardcore_position_config.py` | Hardcore position/action-filter choices, selected-position normalization, config JSON builders/parsers, focus-policy toggles, subcategory allow-list policy, position-key detection, and category/template/axis filtering. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, hardcore cast count policy, and hardcore detail-density directives. | | `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | @@ -484,17 +485,17 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `custom_item`, `item_label` | Category/pair route | Formatters and debug | Label/name for item route. | | `role_graph` | `_role_graph`, POV adapter | Krea/Naturalizer | Choreography/action relationship text after POV adaptation. | | `source_role_graph` | `_role_graph` before POV rewrite | Krea hardcore rewrite | Raw action graph used to infer position and contact. | -| `scene_text` | `_scene_pool` or location config | All formatters | Final location text. | +| `scene_text` | `row_prompt_axes.resolve_prompt_axes` | All formatters | Final location text. | | `source_scene_text` | location/body-exposure/camera adapters | Debug/continuity | Previous scene text before an override. | | `location_config` | Location config parser | Debug | Active location pool config, if connected. | -| `pose` | `_pose_pool` or category item route | Formatters | Generic pose text. Less important for hardcore action categories than `item`/`role_graph`. | -| `expression` | `_expression_pool` and intensity filter | All formatters | Final expression text unless disabled. | -| `shared_expression` | Expression selection | Debug | Expression before character-specific expansion. | -| `character_expression_text` | Character slot expression route | Krea/Naturalizer | Per-character expression clauses. | +| `pose` | `row_prompt_axes.resolve_prompt_axes` | Formatters | Generic pose text. Less important for hardcore action categories than `item`/`role_graph`. | +| `expression` | `row_prompt_axes.resolve_prompt_axes` | All formatters | Final expression text unless disabled. | +| `shared_expression` | `row_prompt_axes.resolve_prompt_axes` | Debug | Expression before character-specific expansion. | +| `character_expression_text` | `row_prompt_axes.resolve_prompt_axes` | Krea/Naturalizer | Per-character expression clauses. | | `expression_enabled`, `expression_disabled` | Builder/slot override | All formatters | Hard gate for whether expression text should appear. | | `expression_intensity_source` | Builder/slot override | Debug | Explains whether intensity came from input, random, slot, or disabled state. | -| `composition` | `_composition_pool`, POV/camera adapter | All formatters | Final framing phrase. | -| `source_composition` | Composition adapter | Krea hardcore rewrite | Previous/raw composition, often better for action inference. | +| `composition` | `row_prompt_axes.resolve_prompt_axes` | All formatters | Final framing phrase. | +| `source_composition` | `row_prompt_axes.resolve_prompt_axes` | Krea hardcore rewrite | Previous/raw composition, often better for action inference. | | `composition_config` | Composition config parser | Debug | Active composition pool config, if connected. | | `camera_config` | Camera nodes/parser | Krea/SDXL/debug | Structured camera settings. | | `camera_directive` | `_camera_directive` | Krea/Naturalizer/prompt text | Human camera sentence. Suppressed for POV. | diff --git a/prompt_builder.py b/prompt_builder.py index 2650d97..0493b25 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -40,6 +40,7 @@ try: from . import row_generation as row_generation_policy from . import row_item as row_item_policy from . import row_location as row_location_policy + from . import row_prompt_axes as row_prompt_axes_policy from . import row_pools as row_pool_policy from . import row_rendering as row_rendering_policy from . import row_route_metadata as row_route_policy @@ -87,6 +88,7 @@ except ImportError: # Allows local smoke tests with `python -c`. import row_generation as row_generation_policy import row_item as row_item_policy import row_location as row_location_policy + import row_prompt_axes as row_prompt_axes_policy import row_pools as row_pool_policy import row_rendering as row_rendering_policy import row_route_metadata as row_route_policy @@ -2029,6 +2031,59 @@ def _composition_pool( return row_pool_policy.composition_pool(category, subcategory, item, subject_type, composition_config) +def _prompt_axes_route( + *, + category: dict[str, Any], + subcategory: dict[str, Any], + item: Any, + subject_type: str, + context: dict[str, Any], + poses: str, + women_count: int, + men_count: int, + scene_rng: random.Random, + pose_rng: random.Random, + expression_rng: random.Random, + composition_rng: random.Random, + expression_disabled: bool, + expression_intensity: float, + character_slots: list[dict[str, Any]] | None = None, + character_slot_map: dict[str, dict[str, Any]] | None = None, + expression_phase: str = "", + source_role_graph: Any = "", + item_axis_values: dict[str, Any] | None = None, + is_pose_category: bool = False, + pov_character_labels: list[str] | None = None, + location_config: dict[str, Any] | None = None, + composition_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + return row_prompt_axes_policy.resolve_prompt_axes( + category=category, + subcategory=subcategory, + item=item, + subject_type=subject_type, + context=context, + poses=poses, + women_count=women_count, + men_count=men_count, + scene_rng=scene_rng, + pose_rng=pose_rng, + expression_rng=expression_rng, + composition_rng=composition_rng, + expression_disabled=expression_disabled, + expression_intensity=expression_intensity, + character_slots=character_slots, + character_slot_map=character_slot_map, + expression_phase=expression_phase, + source_role_graph=source_role_graph, + item_axis_values=item_axis_values, + is_pose_category=is_pose_category, + pov_character_labels=pov_character_labels, + location_config=location_config, + composition_config=composition_config, + ) + + def _build_custom_row( category_choice: str, subcategory_choice: str, @@ -2140,67 +2195,40 @@ def _build_custom_row( if expression_intensity is None: expression_disabled = True - scene_slug, scene = _choose_pair( - scene_rng, - _compatible_entries( - _scene_pool(category, subcategory, item, subject_type, parsed_location_config), - women_count, - men_count, - ), + prompt_axes = _prompt_axes_route( + category=category, + subcategory=subcategory, + item=item, + subject_type=subject_type, + context=context, + poses=poses, + women_count=women_count, + men_count=men_count, + scene_rng=scene_rng, + pose_rng=pose_rng, + expression_rng=expression_rng, + composition_rng=composition_rng, + expression_disabled=expression_disabled, + expression_intensity=expression_intensity, + character_slots=character_slots, + character_slot_map=character_slot_map, + expression_phase=expression_phase, + source_role_graph=source_role_graph, + item_axis_values=item_axis_values, + is_pose_category=is_pose_category, + pov_character_labels=pov_character_labels, + location_config=parsed_location_config, + composition_config=parsed_composition_config, ) - pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text( - pose_rng, _compatible_entries(_pose_pool(category, subcategory, item, subject_type, poses), women_count, men_count) - )) - if is_pose_category: - pose = _sanitize_hardcore_environment_anchors(pose) - expression_pool = _expression_pool(category, subcategory, item) - if expression_disabled: - expression = "" - else: - expression_entries = _compatible_entries( - _expression_entries_for_intensity(expression_pool, expression_intensity), - women_count, - men_count, - ) - expression = _choose_text(expression_rng, expression_entries) - if subject_type in ("couple", "group") and ";" not in expression: - secondary_expression = _choose_distinct_text(expression_rng, expression_entries, expression) - if secondary_expression: - expression = f"{expression}; {secondary_expression}" - shared_expression = expression - character_expressions: list[str] = [] - character_expression_text = "" - if not expression_disabled and subject_type == "configured_cast" and character_slots: - character_expressions = _character_expression_entries( - expression_rng, - expression_pool, - expression_intensity, - character_slot_map, - women_count, - men_count, - expression_phase, - ) - character_expression_text = "; ".join(character_expressions) - character_expression_text = _sanitize_character_expression_text_for_action( - character_expression_text, - source_role_graph, - item, - item_axis_values, - ) - character_expressions = [part.strip() for part in character_expression_text.split(";") if part.strip()] - if character_expression_text: - expression = character_expression_text - source_composition = _choose_text( - composition_rng, - _compatible_entries( - _composition_pool(category, subcategory, item, subject_type, parsed_composition_config), - women_count, - men_count, - ), - ) - if is_pose_category: - source_composition = _sanitize_hardcore_environment_anchors(source_composition) - composition = _pov_composition_prompt(source_composition, pov_character_labels) + scene_slug = str(prompt_axes.get("scene_slug") or "") + scene = str(prompt_axes.get("scene") or "") + pose = str(prompt_axes.get("pose") or "") + expression = str(prompt_axes.get("expression") or "") + shared_expression = str(prompt_axes.get("shared_expression") or "") + character_expressions = list(prompt_axes.get("character_expressions") or []) + character_expression_text = str(prompt_axes.get("character_expression_text") or "") + source_composition = str(prompt_axes.get("source_composition") or "") + composition = str(prompt_axes.get("composition") or "") action_route = _action_position_route_metadata( is_pose_category=is_pose_category, subcategory=subcategory, diff --git a/row_prompt_axes.py b/row_prompt_axes.py new file mode 100644 index 0000000..769c22c --- /dev/null +++ b/row_prompt_axes.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import Any + +try: + from . import category_library as category_policy + from . import row_expression as row_expression_policy + from . import row_item as row_item_policy + from . import row_pools as row_pool_policy + from . import pov_policy + from .hardcore_text_cleanup import sanitize_hardcore_environment_anchors +except ImportError: # Allows local smoke tests from the repository root. + import category_library as category_policy + import row_expression as row_expression_policy + import row_item as row_item_policy + import row_pools as row_pool_policy + import pov_policy + from hardcore_text_cleanup import sanitize_hardcore_environment_anchors + + +def resolve_prompt_axes( + *, + category: dict[str, Any], + subcategory: dict[str, Any], + item: Any, + subject_type: str, + context: dict[str, Any], + poses: str, + women_count: int, + men_count: int, + scene_rng: Any, + pose_rng: Any, + expression_rng: Any, + composition_rng: Any, + expression_disabled: bool, + expression_intensity: float, + character_slots: list[dict[str, Any]] | None = None, + character_slot_map: dict[str, dict[str, Any]] | None = None, + expression_phase: str = "", + source_role_graph: Any = "", + item_axis_values: dict[str, Any] | None = None, + is_pose_category: bool = False, + pov_character_labels: list[str] | None = None, + location_config: dict[str, Any] | None = None, + composition_config: dict[str, Any] | None = None, +) -> dict[str, Any]: + character_slots = character_slots or [] + character_slot_map = character_slot_map or {} + pov_character_labels = pov_character_labels or [] + + scene_slug, scene = row_item_policy.choose_pair( + scene_rng, + category_policy.compatible_entries( + row_pool_policy.scene_pool(category, subcategory, item, subject_type, location_config), + women_count, + men_count, + ), + ) + pose = str( + category_policy.merged_field(category, subcategory, item, "pose", "") + or context.get("fallback_pose") + or row_item_policy.choose_text( + pose_rng, + category_policy.compatible_entries( + row_pool_policy.pose_pool(category, subcategory, item, subject_type, poses), + women_count, + men_count, + ), + ) + ) + if is_pose_category: + pose = sanitize_hardcore_environment_anchors(pose) + + expression_pool = row_pool_policy.expression_pool(category, subcategory, item) + if expression_disabled: + expression = "" + else: + expression_entries = category_policy.compatible_entries( + row_expression_policy.expression_entries_for_intensity(expression_pool, expression_intensity), + women_count, + men_count, + ) + expression = row_item_policy.choose_text(expression_rng, expression_entries) + if subject_type in ("couple", "group") and ";" not in expression: + secondary_expression = row_item_policy.choose_distinct_text(expression_rng, expression_entries, expression) + if secondary_expression: + expression = f"{expression}; {secondary_expression}" + + shared_expression = expression + character_expressions: list[str] = [] + character_expression_text = "" + if not expression_disabled and subject_type == "configured_cast" and character_slots: + character_expressions = row_expression_policy.character_expression_entries( + expression_rng, + expression_pool, + expression_intensity, + character_slot_map, + women_count, + men_count, + expression_phase, + ) + character_expression_text = "; ".join(character_expressions) + character_expression_text = row_expression_policy.sanitize_character_expression_text_for_action( + character_expression_text, + source_role_graph, + item, + item_axis_values or {}, + ) + character_expressions = [part.strip() for part in character_expression_text.split(";") if part.strip()] + if character_expression_text: + expression = character_expression_text + + source_composition = row_item_policy.choose_text( + composition_rng, + category_policy.compatible_entries( + row_pool_policy.composition_pool(category, subcategory, item, subject_type, composition_config), + women_count, + men_count, + ), + ) + if is_pose_category: + source_composition = sanitize_hardcore_environment_anchors(source_composition) + composition = pov_policy.pov_composition_prompt(source_composition, pov_character_labels) + + return { + "scene_slug": scene_slug, + "scene": scene, + "pose": pose, + "expression": expression, + "shared_expression": shared_expression, + "character_expressions": character_expressions, + "character_expression_text": character_expression_text, + "source_composition": source_composition, + "composition": composition, + } diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 910c2cf..35a4025 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -58,6 +58,7 @@ import row_generation # noqa: E402 import row_item # noqa: E402 import row_location # noqa: E402 import row_pools # noqa: E402 +import row_prompt_axes # noqa: E402 import row_rendering # noqa: E402 import row_route_metadata # noqa: E402 import row_subject_route # noqa: E402 @@ -758,6 +759,91 @@ def smoke_row_expression_policy() -> None: ) +def smoke_row_prompt_axes_policy() -> None: + category = { + "name": "Axis Test", + "slug": "axis_test", + "scenes": [{"slug": "studio", "prompt": "quiet studio with repeatable anchors"}], + "poses": ["standing fallback pose"], + "expressions": ["quiet focus", "heated smirk"], + "compositions": ["all participants visible centered frame"], + } + subcategory = {"name": "Axis Sub", "slug": "axis_sub", "items": ["axis item"]} + item = "axis item" + base_kwargs = { + "category": category, + "subcategory": subcategory, + "item": item, + "subject_type": "woman", + "context": {"fallback_pose": ""}, + "poses": "standard", + "women_count": 1, + "men_count": 0, + "expression_disabled": True, + "expression_intensity": 0.5, + "is_pose_category": False, + "location_config": {}, + "composition_config": {}, + } + route = row_prompt_axes.resolve_prompt_axes( + **base_kwargs, + scene_rng=random.Random(1), + pose_rng=random.Random(2), + expression_rng=random.Random(3), + composition_rng=random.Random(4), + ) + delegated = pb._prompt_axes_route( + **base_kwargs, + scene_rng=random.Random(1), + pose_rng=random.Random(2), + expression_rng=random.Random(3), + composition_rng=random.Random(4), + ) + _expect(delegated == route, "Prompt builder prompt-axes route should delegate to row_prompt_axes") + _expect(route["scene_slug"] == "studio", "Prompt axes route lost selected scene slug") + _expect(route["scene"] == "quiet studio with repeatable anchors", "Prompt axes route lost selected scene text") + _expect(route["pose"] == "standing fallback pose", "Prompt axes route lost selected fallback pose") + _expect(route["expression"] == "", "Prompt axes route should omit expression when disabled") + _expect(route["shared_expression"] == "", "Prompt axes route should omit shared expression when disabled") + _expect(route["source_composition"] == "all participants visible centered frame", "Prompt axes route lost source composition") + + pov_route = row_prompt_axes.resolve_prompt_axes( + **{**base_kwargs, "expression_disabled": True}, + scene_rng=random.Random(1), + pose_rng=random.Random(2), + expression_rng=random.Random(3), + composition_rng=random.Random(4), + pov_character_labels=["Man A"], + ) + _expect("first-person POV" in pov_route["composition"], "Prompt axes route did not adapt composition for POV") + + woman_slot = character_slot.normalize_character_slot( + {"subject_type": "woman", "label": "A", "expression_intensity": 0.25} + ) + configured_route = row_prompt_axes.resolve_prompt_axes( + **{ + **base_kwargs, + "subject_type": "configured_cast", + "context": {"subject_type": "configured_cast"}, + "expression_disabled": False, + "character_slots": [woman_slot], + "character_slot_map": {"Woman A": woman_slot}, + }, + scene_rng=random.Random(1), + pose_rng=random.Random(2), + expression_rng=random.Random(3), + composition_rng=random.Random(4), + ) + _expect( + configured_route["character_expression_text"].startswith("Woman A has "), + "Prompt axes route lost configured-cast character expression", + ) + _expect( + configured_route["expression"] == configured_route["character_expression_text"], + "Prompt axes route did not promote character expression text to row expression", + ) + + def smoke_row_item_policy() -> None: weighted_entries = [ {"text": "first", "weight": 0.0}, @@ -4387,6 +4473,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("location_config_policy", smoke_location_config_policy), ("row_location_policy", smoke_row_location_policy), ("row_expression_policy", smoke_row_expression_policy), + ("row_prompt_axes_policy", smoke_row_prompt_axes_policy), ("row_item_policy", smoke_row_item_policy), ("row_category_route_policy", smoke_row_category_route_policy), ("row_generation_policy", smoke_row_generation_policy),