From 17c6d347846d82dcebfe367d69bd73e61b43e44b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 13:30:59 +0200 Subject: [PATCH] Allow inline scene camera profiles --- README.md | 4 ++ docs/prompt-pool-routing-map.md | 4 ++ scene_camera_adapters.py | 90 +++++++++++++++++++++++++++++++++ tools/prompt_smoke.py | 48 ++++++++++++++++++ 4 files changed, 146 insertions(+) diff --git a/README.md b/README.md index 694803d..3670175 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,10 @@ Rows keep the selected `scene_entry`, `location_theme`, `scene_theme`, from prompt text alone. When camera-aware profile routing runs, explicit `scene_camera_profile_key` and theme metadata are used before fallback text matching. +Advanced scene entries may also include an inline `camera_profile` / +`scene_camera_profile` object with `layout_label`, `foreground`, `midground`, +`background`, and optional composition text, so custom location packs can define +their own camera behavior. `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 d4f7468..0e020db 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -656,6 +656,10 @@ Current camera-aware scene adapter: - Profile resolution is metadata-first: explicit `scene_camera_profile_key`, selected `scene_entry` profile keys, and theme metadata are preferred before text matching. +- Selected scene entries can provide inline `camera_profile` or + `scene_camera_profile` objects with `key`, `family`, `layout_label`, `place`, + `foreground`, `midground`, `background`, `detail_label`, and optional + per-subject `composition` text. - 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 diff --git a/scene_camera_adapters.py b/scene_camera_adapters.py index 15ffc78..a5a0daa 100644 --- a/scene_camera_adapters.py +++ b/scene_camera_adapters.py @@ -118,6 +118,17 @@ THEME_PROFILE_KEYS = { "classical_library": "classical_library", } +PROFILE_TEXT_FIELDS = ( + "key", + "family", + "layout_label", + "place", + "foreground", + "midground", + "background", + "detail_label", +) + MISMATCHED_COMPOSITION_TERMS = ( "outfit-check", "outfit check", @@ -129,6 +140,10 @@ MISMATCHED_COMPOSITION_TERMS = ( ) +def _clean_text(value: Any) -> str: + return " ".join(str(value or "").strip().split()) + + def _profile_by_key(value: Any) -> dict[str, Any]: key = str(value or "").strip() if not key: @@ -141,6 +156,62 @@ def _profile_by_key(value: Any) -> dict[str, Any]: return {} +def _profile_title(value: str) -> str: + text = _clean_text(value).replace("_", " ").replace("-", " ") + if not text: + return "Scene" + return " ".join(part[:1].upper() + part[1:] for part in text.split()) + + +def _default_composition(profile: dict[str, Any]) -> dict[str, str]: + place = _clean_text(profile.get("place")) or "scene" + foreground = _clean_text(profile.get("foreground")) or "foreground anchor" + background = _clean_text(profile.get("background")) or "environment depth" + return { + "woman": f"{place} frame with the woman near {foreground} and {background} behind her", + "man": f"{place} frame with the man near {foreground} and {background} behind him", + "default": f"{place} frame with the subjects near {foreground} and {background} behind them", + } + + +def normalize_scene_camera_profile(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + base = _profile_by_key(value.get("base_profile_key") or value.get("extends")) + merged = dict(base) + for key, raw_value in value.items(): + if key in ("base_profile_key", "extends"): + continue + merged[key] = raw_value + has_profile_fields = any(_clean_text(merged.get(key)) for key in ("layout_label", "place", "foreground", "midground", "background")) + if not has_profile_fields: + return {} + key = _clean_text(merged.get("key") or merged.get("slug") or merged.get("name") or base.get("key") or "custom_scene") + place = _clean_text(merged.get("place") or merged.get("name") or key.replace("_", " ")) + profile = {field: _clean_text(merged.get(field)) for field in PROFILE_TEXT_FIELDS} + profile["key"] = key + profile["family"] = profile["family"] or "custom" + profile["place"] = place + profile["layout_label"] = profile["layout_label"] or f"{_profile_title(place)} camera layout" + profile["foreground"] = profile["foreground"] or base.get("foreground", "foreground anchor") + profile["midground"] = profile["midground"] or base.get("midground", "midground environment anchors") + profile["background"] = profile["background"] or base.get("background", "background depth") + profile["detail_label"] = profile["detail_label"] or f"{place} details" + composition = merged.get("composition") + if isinstance(composition, dict): + profile["composition"] = { + str(key): _clean_text(text) + for key, text in composition.items() + if _clean_text(text) + } + else: + base_composition = base.get("composition") if isinstance(base.get("composition"), dict) else {} + profile["composition"] = dict(base_composition) if base_composition else _default_composition(profile) + if not profile["composition"]: + profile["composition"] = _default_composition(profile) + return profile + + def _scene_entry_text(scene_entry: Any) -> str: if not isinstance(scene_entry, dict): return "" @@ -165,6 +236,19 @@ def _scene_entry_profile_key(scene_entry: Any) -> str: ).strip() +def _scene_entry_profile(scene_entry: Any) -> dict[str, Any]: + if not isinstance(scene_entry, dict): + return {} + for key in ("scene_camera_profile", "camera_profile"): + profile = normalize_scene_camera_profile(scene_entry.get(key)) + if profile: + return profile + profile = normalize_scene_camera_profile(scene_entry.get("profile")) + if profile: + return profile + return normalize_scene_camera_profile(scene_entry) + + def scene_camera_profile( scene_text: Any = "", *, @@ -172,9 +256,15 @@ def scene_camera_profile( theme: Any = "", profile_key: Any = "", ) -> dict[str, Any]: + inline_explicit_profile = normalize_scene_camera_profile(profile_key) + if inline_explicit_profile: + return inline_explicit_profile explicit_profile = _profile_by_key(profile_key) if explicit_profile: return explicit_profile + inline_entry_profile = _scene_entry_profile(scene_entry) + if inline_entry_profile: + return inline_entry_profile entry_profile = _profile_by_key(_scene_entry_profile_key(scene_entry)) if entry_profile: return entry_profile diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 311aa52..f16e4d8 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -623,6 +623,54 @@ def smoke_row_camera_policy() -> None: "Library camera layout" in str(updated_explicit_profile.get("camera_scene_directive", "")), "explicit scene_camera_profile_key should override text-matched scene profile", ) + inline_profile_row = { + "prompt": "A generated adult prompt. Composition: vertical polished mirror view with bag and shoes visible. Avoid: low quality.", + "caption": "sxcppnl7, generated adult prompt, polished mirror view with bag and shoes visible, illustration", + "scene_text": "private room with soft daylight", + "scene_entry": { + "slug": "greenhouse_room", + "prompt": "private room with soft daylight", + "camera_profile": { + "key": "glass_conservatory", + "family": "greenhouse", + "layout_label": "Glass conservatory camera layout", + "place": "glass conservatory", + "foreground": "plant shelf edge, fern leaves, and iron table corner", + "midground": "glass panes, iron ribs, and potted palms", + "background": "hanging vines, greenhouse windows, and layered plant depth", + "detail_label": "conservatory details", + "composition": { + "woman": "glass conservatory frame with the woman beside fern leaves and greenhouse depth behind her", + "default": "glass conservatory frame with the subjects beside fern leaves and greenhouse depth behind them", + }, + }, + }, + "composition": "polished mirror view with bag and shoes visible", + "subject_type": "woman", + "women_count": 1, + "men_count": 0, + } + updated_inline_profile = row_camera.apply_camera_config( + inline_profile_row, + _orbit_camera(horizontal_angle=45, vertical_angle=30, zoom=5.0), + compact_labels=pb.CAMERA_COMPACT_LABELS, + ) + inline_scene = _expect_text( + "row_camera_policy.inline_profile_scene", + updated_inline_profile.get("camera_scene_directive"), + 40, + ) + inline_composition = _expect_text( + "row_camera_policy.inline_profile_composition", + updated_inline_profile.get("composition"), + 20, + ) + inline_profile = updated_inline_profile.get("scene_camera_profile") if isinstance(updated_inline_profile.get("scene_camera_profile"), dict) else {} + _expect("Glass conservatory camera layout" in inline_scene, "inline scene camera profile did not drive camera layout") + _expect(updated_inline_profile.get("scene_camera_profile_key") == "glass_conservatory", "inline profile key was not exposed") + _expect(inline_profile.get("family") == "greenhouse", "inline profile family was not exposed") + _expect("glass conservatory" in inline_composition.lower(), "inline profile did not drive composition cleanup") + _expect("bag" not in inline_composition.lower() and "shoes" not in inline_composition.lower(), "inline profile composition leaked unrelated props") def smoke_config_route_location_theme() -> None: