From 75a71a2df6eae7f4e4348b088322fba5115ed93e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 13:21:51 +0200 Subject: [PATCH] Preserve location route metadata --- README.md | 4 ++ docs/prompt-pool-routing-map.md | 7 ++++ location_config.py | 29 +++++++++++-- prompt_builder.py | 4 ++ row_assembly.py | 13 ++++++ row_camera.py | 16 ++++++++ row_location.py | 40 +++++++++++++++++- row_prompt_axes.py | 54 ++++++++++++++++++------- tools/prompt_smoke.py | 72 +++++++++++++++++++++++++++++++-- 9 files changed, 215 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e922d7e..1c8dcab 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,10 @@ spaces: front/side/back views, zoom, and elevation change which desks, windows, partitions, bookshelves, reading tables, lamps, or aisles are kept visible. In male-POV setups this becomes a first-person spatial description and the external camera sentence is suppressed. +Rows keep the selected `scene_entry`, `location_theme`, `scene_theme`, +`composition_entry`, `composition_theme`, and `scene_camera_profile_key` in +`metadata_json` so location/camera behavior can be debugged without guessing +from prompt text alone. `SxCP SDXL Formatter` rewrites prompt builder output or `metadata_json` into comma-tag SDXL/Pony-style prompts. Connect `metadata_json` when possible so diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 6e1a9fe..d6f1ca9 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -508,6 +508,8 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `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` | `row_prompt_axes.resolve_prompt_axes` | All formatters | Final location text. | +| `scene_entry` | `row_prompt_axes.resolve_prompt_axes` / `row_location` | Debug/future route rules | Structured selected scene entry, preserving slug/prompt plus theme metadata when available. | +| `location_theme`, `scene_theme` | `location_config.py`, selected scene entry | Debug/camera route rules | Active theme on the location config and theme of the selected scene. This makes theme-driven behavior inspectable instead of only string-inferred. | | `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` | `row_prompt_axes.resolve_prompt_axes` | Formatters | Generic pose text. Less important for hardcore action categories than `item`/`role_graph`. | @@ -517,11 +519,13 @@ plain prompt text. When debugging, inspect these fields before editing pools. | `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` | `row_prompt_axes.resolve_prompt_axes` | All formatters | Final framing phrase. | +| `composition_entry`, `composition_theme` | `row_prompt_axes.resolve_prompt_axes` / `row_location` | Debug/future route rules | Structured selected composition entry and active composition theme. | | `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. | | `camera_scene_directive` | scene-camera adapter | Krea/Naturalizer/prompt text | Location-aware camera layout sentence. | +| `scene_camera_profile`, `scene_camera_profile_key` | `row_camera.apply_camera_config` | Debug/camera route rules | Structured camera profile selected for the current scene, e.g. `classical_library` or `coworking_lounge`. | | `subject_type`, `subject_phrase` | `row_subject_route.resolve_subject_route` | Formatters | Single/couple/group/configured cast route. | | `women_count`, `men_count`, `person_count` | `row_subject_route.resolve_subject_route` | Pair/formatters/debug | Effective cast counts. | | `cast_descriptors`, `cast_descriptor_text` | `row_subject_route.resolve_subject_route` | Krea/SDXL/Naturalizer | Visible cast descriptors. | @@ -651,6 +655,9 @@ Current camera-aware scene adapter: - Scene profiles live in `scene_camera_adapters.SCENE_CAMERA_PROFILES`. - Coworking/business-cafe/office scenes and classical library/book-stack scenes are detected by `scene_camera_profile`. +- Location themes preserve `theme` on configs and selected scene entries, and + rows expose `location_theme`, `scene_theme`, `composition_theme`, and + `scene_camera_profile_key` for debugging and future route rules. - Direction, distance, and elevation details come from profile-aware helpers such as `scene_direction_detail`, `scene_distance_detail`, and `scene_elevation_detail`. diff --git a/location_config.py b/location_config.py index 487d0e2..c4e40ee 100644 --- a/location_config.py +++ b/location_config.py @@ -322,6 +322,7 @@ def build_location_pool_json( merged_entries = entries active = bool(enabled) and bool(merged_entries) + theme = str(incoming.get("theme") or "") if combine_mode == "add" and incoming.get("enabled") else "" summary = ( f"{apply_mode}; pools={len(merged_pool_names)}; locations={len(merged_entries)}" if active @@ -334,6 +335,7 @@ def build_location_pool_json( "pool_names": merged_pool_names, "scene_entries": merged_entries, "summary": summary, + "theme": theme, }, ensure_ascii=True, sort_keys=True, @@ -342,7 +344,7 @@ def build_location_pool_json( def parse_location_config(location_config: str | dict[str, Any] | None) -> dict[str, Any]: if not location_config: - return {"enabled": False, "apply_mode": "replace", "pool_names": [], "scene_entries": []} + return {"enabled": False, "apply_mode": "replace", "pool_names": [], "scene_entries": [], "theme": ""} if isinstance(location_config, dict): raw = dict(location_config) else: @@ -361,6 +363,7 @@ def parse_location_config(location_config: str | dict[str, Any] | None) -> dict[ "pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()], "scene_entries": entries, "summary": str(raw.get("summary") or ""), + "theme": str(raw.get("theme") or ""), } @@ -432,6 +435,7 @@ def build_composition_pool_json( merged_entries = entries active = bool(enabled) and bool(merged_entries) + theme = str(incoming.get("theme") or "") if combine_mode == "add" and incoming.get("enabled") else "" summary = ( f"{apply_mode}; pools={len(merged_pool_names)}; compositions={len(merged_entries)}" if active @@ -444,6 +448,7 @@ def build_composition_pool_json( "pool_names": merged_pool_names, "composition_entries": merged_entries, "summary": summary, + "theme": theme, }, ensure_ascii=True, sort_keys=True, @@ -452,7 +457,7 @@ def build_composition_pool_json( def parse_composition_config(composition_config: str | dict[str, Any] | None) -> dict[str, Any]: if not composition_config: - return {"enabled": False, "apply_mode": "replace", "pool_names": [], "composition_entries": []} + return {"enabled": False, "apply_mode": "replace", "pool_names": [], "composition_entries": [], "theme": ""} if isinstance(composition_config, dict): raw = dict(composition_config) else: @@ -471,6 +476,7 @@ def parse_composition_config(composition_config: str | dict[str, Any] | None) -> "pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()], "composition_entries": entries, "summary": str(raw.get("summary") or ""), + "theme": str(raw.get("theme") or ""), } @@ -512,8 +518,23 @@ def build_thematic_location_json( custom_compositions=composition_lines, composition_config=composition_config or "", ) - location_summary = json.loads(resolved_location_config).get("summary", "") - composition_summary = json.loads(resolved_composition_config).get("summary", "") + location_payload = json.loads(resolved_location_config) + composition_payload = json.loads(resolved_composition_config) + location_payload["theme"] = str(theme or "") + composition_payload["theme"] = str(theme or "") + themed_scene_entries = [] + for entry in location_payload.get("scene_entries") or []: + if isinstance(entry, dict): + themed_entry = dict(entry) + themed_entry.setdefault("theme", str(theme or "")) + themed_scene_entries.append(themed_entry) + else: + themed_scene_entries.append(entry) + location_payload["scene_entries"] = themed_scene_entries + resolved_location_config = json.dumps(location_payload, ensure_ascii=True, sort_keys=True) + resolved_composition_config = json.dumps(composition_payload, ensure_ascii=True, sort_keys=True) + location_summary = location_payload.get("summary", "") + composition_summary = composition_payload.get("summary", "") summary = f"{theme}; locations={location_summary}; compositions={composition_summary}" return resolved_location_config, resolved_composition_config, summary diff --git a/prompt_builder.py b/prompt_builder.py index 0850abe..cbaa5ed 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -2380,6 +2380,7 @@ def _build_custom_row( ) scene_slug = prompt_axes.scene_slug scene = prompt_axes.scene + scene_entry = dict(prompt_axes.scene_entry) pose = prompt_axes.pose expression = prompt_axes.expression shared_expression = prompt_axes.shared_expression @@ -2387,6 +2388,7 @@ def _build_custom_row( character_expression_text = prompt_axes.character_expression_text source_composition = prompt_axes.source_composition composition = prompt_axes.composition + composition_entry = dict(prompt_axes.composition_entry) action_route = _action_position_route( is_pose_category=is_pose_category, subcategory=subcategory, @@ -2424,6 +2426,7 @@ def _build_custom_row( negative_prompt=text_fields.negative_prompt, scene_slug=scene_slug, scene=scene, + scene_entry=scene_entry, pose=pose, expression=expression, shared_expression=shared_expression, @@ -2434,6 +2437,7 @@ def _build_custom_row( expression_intensity_source=expression_intensity_source, composition=composition, source_composition=source_composition, + composition_entry=composition_entry, role_graph=role_graph, source_role_graph=source_role_graph, action_family=action_family, diff --git a/row_assembly.py b/row_assembly.py index 3145f71..e124763 100644 --- a/row_assembly.py +++ b/row_assembly.py @@ -37,6 +37,7 @@ class CustomRowAssemblyRequest: negative_prompt: str scene_slug: str scene: str + scene_entry: dict[str, Any] pose: str expression: str shared_expression: str @@ -47,6 +48,7 @@ class CustomRowAssemblyRequest: expression_intensity_source: str composition: str source_composition: str + composition_entry: dict[str, Any] role_graph: str source_role_graph: str action_family: str @@ -85,6 +87,7 @@ def assemble_custom_row(request: CustomRowAssemblyRequest) -> dict[str, Any]: "style": r.style, "scene": r.scene, "scene_slug": r.scene_slug, + "scene_entry": r.scene_entry, "pose": r.pose, "expression": r.expression, "shared_expression": r.shared_expression, @@ -95,6 +98,7 @@ def assemble_custom_row(request: CustomRowAssemblyRequest) -> dict[str, Any]: "expression_intensity": r.expression_intensity, "expression_intensity_source": r.expression_intensity_source, "composition": r.composition, + "composition_entry": r.composition_entry, "source_composition": r.source_composition, "composition_prompt": row_camera_policy.composition_prompt(r.composition), "composition_config": r.composition_config or {}, @@ -156,6 +160,13 @@ def assemble_custom_row(request: CustomRowAssemblyRequest) -> dict[str, Any]: "item_template_metadata": r.item_template_metadata, "formatter_hints": r.formatter_hints, "scene_text": r.scene, + "scene_entry": r.scene_entry, + "location_theme": (r.location_config or {}).get("theme", ""), + "scene_theme": r.scene_entry.get("theme", "") or ( + (r.location_config or {}).get("theme", "") + if (r.location_config or {}).get("apply_mode") == "replace" + else "" + ), "location_config": r.location_config or {}, "pose": r.pose, "seed_config": r.seed_config, @@ -168,6 +179,8 @@ def assemble_custom_row(request: CustomRowAssemblyRequest) -> dict[str, Any]: "position_key": r.position_key, "position_keys": r.position_keys, "source_composition": r.source_composition, + "composition_entry": r.composition_entry, + "composition_theme": (r.composition_config or {}).get("theme", ""), "pov_character_labels": r.pov_character_labels, "pov_prompt_directive": pov_prompt_directive, "shared_expression": r.shared_expression, diff --git a/row_camera.py b/row_camera.py index a4c6ca8..c3f0763 100644 --- a/row_camera.py +++ b/row_camera.py @@ -70,6 +70,18 @@ def apply_contextual_composition(row: dict[str, Any], subject_kind: str) -> dict return row +def scene_camera_profile_metadata(scene_text: Any) -> dict[str, str]: + profile = scene_camera_adapters.scene_camera_profile(scene_text) + if not profile: + return {} + return { + "key": str(profile.get("key") or ""), + "family": str(profile.get("family") or ""), + "layout_label": str(profile.get("layout_label") or ""), + "place": str(profile.get("place") or ""), + } + + def camera_scene_directive_for_context( scene_text: Any, composition: Any, @@ -129,6 +141,10 @@ def apply_camera_config( pov_labels = row_pov_labels(row, pov_label_resolver) subject_kind = row_camera_subject_kind(row) row = apply_contextual_composition(row, subject_kind) + profile_metadata = scene_camera_profile_metadata(row.get("scene_text") or row.get("source_scene_text") or row.get("scene")) + if profile_metadata: + row["scene_camera_profile"] = profile_metadata + row["scene_camera_profile_key"] = profile_metadata.get("key", "") scene_directive, parsed = camera_scene_directive_for_context( row.get("scene_text") or row.get("source_scene_text") or row.get("scene"), row.get("composition") or row.get("source_composition"), diff --git a/row_location.py b/row_location.py index 3a4e9a5..9682852 100644 --- a/row_location.py +++ b/row_location.py @@ -89,8 +89,31 @@ def _choose_pair(rng: random.Random, items: list[Any]) -> tuple[str, str]: return _pair_from(_weighted_choice(rng, items)) +def _metadata_entry(value: Any, *, slug: str = "", text: str = "") -> dict[str, Any]: + if isinstance(value, dict): + entry = dict(value) + elif isinstance(value, (list, tuple)) and len(value) == 2: + entry = {"slug": str(value[0]), "prompt": str(value[1])} + else: + entry = {"prompt": str(value or "")} + if slug: + entry["slug"] = slug + if text: + if "prompt" in entry: + entry["prompt"] = text + elif "text" in entry: + entry["text"] = text + else: + entry["prompt"] = text + return entry + + def _choose_text(rng: random.Random, items: list[Any]) -> str: item = _weighted_choice(rng, items) + return _text_from_entry(item) + + +def _text_from_entry(item: Any) -> str: if isinstance(item, dict): return str( item.get("template") @@ -134,13 +157,22 @@ def apply_location_config_to_legacy_row( else: choices = location_entries scene_rng = seed_policy.axis_rng(seed_config, "scene", seed, row_number) - scene_slug, scene_text = _choose_pair(scene_rng, choices) + scene_choice = _weighted_choice(scene_rng, choices) + scene_slug, scene_text = _pair_from(scene_choice) + scene_entry = _metadata_entry(scene_choice, slug=scene_slug, text=scene_text) old_slug = str(row.get("scene") or "") old_text = legacy_scene_text_for_slug(old_slug) row["source_scene"] = old_slug row["source_scene_text"] = old_text row["scene"] = scene_slug row["scene_text"] = scene_text + row["scene_entry"] = scene_entry + row["location_theme"] = str(location_config.get("theme") or "") + row["scene_theme"] = scene_entry.get("theme", "") or ( + str(location_config.get("theme") or "") + if location_config.get("apply_mode") == "replace" + else "" + ) row["location_config"] = location_config if old_text: row["prompt"] = str(row.get("prompt") or "").replace(f"Scene: {old_text}.", f"Scene: {scene_text}.") @@ -178,12 +210,16 @@ def apply_composition_config_to_legacy_row( else: choices = composition_entries composition_rng = seed_policy.axis_rng(seed_config, "composition", seed, row_number) - new_composition = _choose_text(composition_rng, choices) + composition_choice = _weighted_choice(composition_rng, choices) + new_composition = _text_from_entry(composition_choice) + composition_entry = _metadata_entry(composition_choice, text=new_composition) old_composition = str(row.get("composition") or "") old_prompt_fragment = f"Composition: vertical {old_composition}." new_prompt_fragment = f"Composition: {row_camera.composition_prompt(new_composition)}." row["source_composition"] = old_composition row["composition"] = new_composition + row["composition_entry"] = composition_entry + row["composition_theme"] = str(composition_config.get("theme") or "") row["composition_prompt"] = row_camera.composition_prompt(new_composition) row["composition_config"] = composition_config if old_composition: diff --git a/row_prompt_axes.py b/row_prompt_axes.py index e6964be..2b955a1 100644 --- a/row_prompt_axes.py +++ b/row_prompt_axes.py @@ -23,6 +23,7 @@ except ImportError: # Allows local smoke tests from the repository root. class PromptAxesRoute: scene_slug: str scene: str + scene_entry: dict[str, Any] pose: str expression: str shared_expression: str @@ -30,11 +31,13 @@ class PromptAxesRoute: character_expression_text: str source_composition: str composition: str + composition_entry: dict[str, Any] def as_dict(self) -> dict[str, Any]: return { "scene_slug": self.scene_slug, "scene": self.scene, + "scene_entry": dict(self.scene_entry), "pose": self.pose, "expression": self.expression, "shared_expression": self.shared_expression, @@ -42,9 +45,29 @@ class PromptAxesRoute: "character_expression_text": self.character_expression_text, "source_composition": self.source_composition, "composition": self.composition, + "composition_entry": dict(self.composition_entry), } +def _metadata_entry(value: Any, *, slug: str = "", text: str = "") -> dict[str, Any]: + if isinstance(value, dict): + entry = dict(value) + elif isinstance(value, (list, tuple)) and len(value) == 2: + entry = {"slug": str(value[0]), "prompt": str(value[1])} + else: + entry = {"prompt": str(value or "")} + if slug: + entry["slug"] = slug + if text: + if "prompt" in entry: + entry["prompt"] = text + elif "text" in entry: + entry["text"] = text + else: + entry["prompt"] = text + return entry + + def resolve_prompt_axes_result( *, category: dict[str, Any], @@ -75,14 +98,14 @@ def resolve_prompt_axes_result( 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, - ), + scene_entries = category_policy.compatible_entries( + row_pool_policy.scene_pool(category, subcategory, item, subject_type, location_config), + women_count, + men_count, ) + scene_choice = row_item_policy.weighted_choice(scene_rng, scene_entries) + scene_slug, scene = row_item_policy.pair_from(scene_choice) + scene_entry = _metadata_entry(scene_choice, slug=scene_slug, text=scene) pose = str( category_policy.merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") @@ -137,21 +160,23 @@ def resolve_prompt_axes_result( 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, - ), + composition_entries = category_policy.compatible_entries( + row_pool_policy.composition_pool(category, subcategory, item, subject_type, composition_config), + women_count, + men_count, ) + composition_choice = row_item_policy.weighted_choice(composition_rng, composition_entries) + source_composition = row_item_policy.item_text(composition_choice) + composition_entry = _metadata_entry(composition_choice, text=source_composition) if is_pose_category: source_composition = sanitize_hardcore_environment_anchors(source_composition) + composition_entry["prompt"] = source_composition composition = pov_policy.pov_composition_prompt(source_composition, pov_character_labels) return PromptAxesRoute( scene_slug=scene_slug, scene=scene, + scene_entry=scene_entry, pose=pose, expression=expression, shared_expression=shared_expression, @@ -159,6 +184,7 @@ def resolve_prompt_axes_result( character_expression_text=character_expression_text, source_composition=source_composition, composition=composition, + composition_entry=composition_entry, ) diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 0dd3f68..cf16c69 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -611,9 +611,16 @@ def smoke_config_route_location_theme() -> None: composition = _expect_text("config_route_location_theme.composition", row.get("composition"), 10) camera = _expect_text("config_route_location_theme.camera_directive", row.get("camera_directive"), 20) scene_directive = _expect_text("config_route_location_theme.camera_scene_directive", row.get("camera_scene_directive"), 40) + scene_profile = row.get("scene_camera_profile") if isinstance(row.get("scene_camera_profile"), dict) else {} _expect("library" in scene.lower() or "bookshelves" in scene.lower(), "location theme did not drive scene") _expect("books" in composition.lower() or "shelf" in composition.lower() or "library" in composition.lower(), "location theme did not drive composition") + _expect(row.get("location_theme") == "classical_library", "location theme did not survive into row metadata") + _expect(row.get("scene_theme") == "classical_library", "selected scene theme did not survive into row metadata") + _expect(row.get("composition_theme") == "classical_library", "composition theme did not survive into row metadata") + _expect(row.get("scene_entry", {}).get("theme") == "classical_library", "selected scene entry lost theme metadata") _expect("Library camera layout" in scene_directive, "location theme did not drive library camera-scene adapter") + _expect(row.get("scene_camera_profile_key") == "classical_library", "row lost scene camera profile key") + _expect(scene_profile.get("family") == "library", "row lost scene camera profile family") _expect("front-left quarter view" in scene_directive, "library camera-scene adapter missed orbit direction") _expect("bag" not in composition.lower() and "shoes" not in composition.lower(), "location theme composition leaked outfit-check props") _expect("315-degree front-left quarter view" in camera, "config route did not preserve orbit camera directive") @@ -964,8 +971,44 @@ def smoke_location_config_policy() -> None: theme="classical_library", ) _expect("classical_library" in theme_summary, "Themed location summary lost theme name") - _expect(json.loads(themed_location).get("scene_entries"), "Themed location did not output locations") - _expect(json.loads(themed_composition).get("composition_entries"), "Themed location did not output compositions") + themed_location_payload = json.loads(themed_location) + themed_composition_payload = json.loads(themed_composition) + _expect(themed_location_payload.get("scene_entries"), "Themed location did not output locations") + _expect(themed_location_payload.get("theme") == "classical_library", "Themed location config lost theme metadata") + _expect( + all( + not isinstance(entry, dict) or entry.get("theme") == "classical_library" + for entry in themed_location_payload.get("scene_entries") or [] + ), + "Themed location entries lost theme metadata", + ) + _expect(themed_composition_payload.get("composition_entries"), "Themed location did not output compositions") + _expect(themed_composition_payload.get("theme") == "classical_library", "Themed composition config lost theme metadata") + parsed_themed = pb._parse_location_config(themed_location_payload) + _expect(parsed_themed.get("theme") == "classical_library", "Location parser lost theme metadata") + replaced_after_theme = json.loads( + location_config.build_location_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_locations="plain_room: plain room after theme", + location_config=themed_location_payload, + ) + ) + _expect(replaced_after_theme.get("theme") == "", "Location replace mode should not inherit upstream theme metadata") + replaced_composition_after_theme = json.loads( + location_config.build_composition_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_compositions="plain composition after theme", + composition_config=themed_composition_payload, + ) + ) + _expect( + replaced_composition_after_theme.get("theme") == "", + "Composition replace mode should not inherit upstream theme metadata", + ) def smoke_row_location_policy() -> None: @@ -997,11 +1040,16 @@ def smoke_row_location_policy() -> None: "Row location policy did not apply forced custom scene text", ) _expect(updated.get("source_scene") == "unknown_old_scene", "Row location policy lost source scene slug") + _expect(updated.get("scene_entry", {}).get("slug") == "archive_corner", "Row location policy lost selected scene entry") _expect( "Scene: hidden archive corner with repeated shelves and warm table lamps. Pose:" in updated.get("prompt", ""), "Row location policy did not rewrite prompt scene", ) _expect(updated.get("composition") == "long archive aisle composition", "Row location policy did not apply forced composition") + _expect( + updated.get("composition_entry", {}).get("prompt") == "long archive aisle composition", + "Row location policy lost selected composition entry", + ) _expect( updated.get("composition_prompt") == "vertical long archive aisle composition", "Row location policy did not compute composition prompt", @@ -1239,10 +1287,19 @@ def smoke_row_prompt_axes_policy() -> None: _expect(route_result.scene_slug == "studio", "Typed prompt axes route lost selected scene slug") _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["scene_entry"].get("slug") == "studio", "Prompt axes route lost selected scene entry slug") + _expect( + route["scene_entry"].get("prompt") == "quiet studio with repeatable anchors", + "Prompt axes route lost selected scene entry prompt", + ) _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") + _expect( + route["composition_entry"].get("prompt") == "all participants visible centered frame", + "Prompt axes route lost selected composition entry", + ) pov_route = row_prompt_axes.resolve_prompt_axes( **{**base_kwargs, "expression_disabled": True}, @@ -2348,6 +2405,7 @@ def smoke_row_assembly_policy() -> None: "negative_prompt": "bad anatomy", "scene_slug": "test_room", "scene": "warm test room", + "scene_entry": {"slug": "test_room", "prompt": "warm test room", "theme": "fixture_theme"}, "pose": "standing close", "expression": "focused look", "shared_expression": "focused look", @@ -2358,6 +2416,7 @@ def smoke_row_assembly_policy() -> None: "expression_intensity_source": "disabled", "composition": "centered frame", "source_composition": "centered frame", + "composition_entry": {"prompt": "centered frame"}, "role_graph": "the visible partner stays centered", "source_role_graph": "Man A stays centered", "action_family": "test_action", @@ -2369,8 +2428,8 @@ def smoke_row_assembly_policy() -> None: "cast_descriptor_text": "Woman A: adult woman; Man A: adult man", "seed_config": {"content_seed": 123}, "hardcore_position_config": {"family": "standing"}, - "location_config": {"location": "test_room"}, - "composition_config": {"composition": "centered"}, + "location_config": {"location": "test_room", "theme": "fixture_theme", "apply_mode": "replace"}, + "composition_config": {"composition": "centered", "theme": "fixture_theme"}, "content_seed_axis": "pose", "count_adjustment": count_adjustment, "applied_profile": {"name": "profile_a"}, @@ -2390,6 +2449,11 @@ def smoke_row_assembly_policy() -> None: _expect(row["source"] == "json_category", "Row assembly lost source marker") _expect(row["figure"] == "balanced cast", "Row assembly lost figure metadata") _expect(row["formatter_hints"] == {"krea": ["test_hint"]}, "Row assembly lost formatter hints") + _expect(row["scene_entry"].get("slug") == "test_room", "Row assembly lost selected scene entry") + _expect(row["location_theme"] == "fixture_theme", "Row assembly lost location theme") + _expect(row["scene_theme"] == "fixture_theme", "Row assembly lost selected scene theme") + _expect(row["composition_entry"].get("prompt") == "centered frame", "Row assembly lost selected composition entry") + _expect(row["composition_theme"] == "fixture_theme", "Row assembly lost composition theme") _expect(row["cast_count_adjustment"] == count_adjustment, "Row assembly lost configured-cast count adjustment") _expect(row["content_seed_axis"] == "pose", "Row assembly lost content seed axis") _expect("POV participant: Man A" in row["prompt"], "Row assembly lost POV prompt directive")