From fef2bf6d815ec351f5e8026db8747ed754be67bd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 00:12:21 +0200 Subject: [PATCH] Extract location config policy --- docs/prompt-architecture-improvement-plan.md | 4 + docs/prompt-pool-routing-map.md | 5 +- location_config.py | 530 +++++++++++++++++++ node_route_config.py | 16 +- prompt_builder.py | 112 ++++ tools/prompt_smoke.py | 57 ++ 6 files changed, 716 insertions(+), 8 deletions(-) create mode 100644 location_config.py diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index a1bc898..1f4287b 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -104,6 +104,10 @@ Already isolated: - JSON category loading, subcategory normalization, named scene/expression/ composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging live in `category_library.py`. +- 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 + to rows. - hardcore configured-cast role graph generation lives in `hardcore_role_graphs.py`; `prompt_builder.py` selects item/axis metadata and then asks that module for the source role graph. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 1434b43..1e6af1d 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. | | `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | | `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. | | `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. | | `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | @@ -264,7 +265,7 @@ Edit targets: - Add reusable named locations: `categories/location_pools.json`. - Add category-specific locations: the category JSON file. - Add quick workflow-only locations: `SxCP Location Pool` custom locations. -- Add themed location packs: `THEMATIC_LOCATION_PRESETS` in `prompt_builder.py`. +- Add themed location packs: `THEMATIC_LOCATION_PRESETS` in `location_config.py`. ### Expression @@ -885,7 +886,7 @@ Use these traces to narrow a problem in one pass. location pools. 3. If a scene-camera adapter rewrote composition, inspect `scene_camera_adapters.py`. -4. If the issue comes from `Location Theme`, edit `THEMATIC_LOCATION_PRESETS`. +4. If the issue comes from `Location Theme`, edit `location_config.py` / `THEMATIC_LOCATION_PRESETS`. ### Trigger missing after formatting diff --git a/location_config.py b/location_config.py new file mode 100644 index 0000000..487d0e2 --- /dev/null +++ b/location_config.py @@ -0,0 +1,530 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +try: + from .category_library import load_composition_pool_library, load_scene_pool_library +except ImportError: # Allows local smoke tests from the repository root. + from category_library import load_composition_pool_library, load_scene_pool_library + + +LOCATION_POOL_PRESETS = { + "custom_only": (), + "all_json_locations": ("*",), + "casual_all": ("casual_",), + "casual_urban": ("casual_urban_scenes",), + "casual_summer": ("casual_summer_scenes",), + "casual_home": ("casual_lounge_scenes",), + "casual_smart": ("casual_smart_scenes",), + "creator_softcore": ("softcore_creator_scenes", "mirror_scenes", "boudoir_bedroom_scenes"), + "mirror_rooms": ("mirror_scenes", "hardcore_mirror_scenes"), + "boudoir_bedroom": ("boudoir_bedroom_scenes", "hardcore_bed_scenes"), + "fetish_studio": ("fetish_studio_scenes",), + "costume_backstage": ("costume_backstage_scenes",), + "hardcore_all": ("hardcore_",), + "hardcore_private": ("hardcore_private_scenes",), + "hardcore_bed": ("hardcore_bed_scenes",), + "hardcore_penetrative": ("hardcore_penetrative_scenes",), + "hardcore_oral": ("hardcore_oral_scenes",), + "hardcore_anal": ("hardcore_anal_scenes",), + "hardcore_threesome": ("hardcore_threesome_scenes",), + "hardcore_group": ("hardcore_group_scenes",), + "hardcore_climax": ("hardcore_climax_scenes",), +} + +COMPOSITION_POOL_PRESETS = { + "custom_only": (), + "all_json_compositions": ("*",), + "casual_all": ("casual_", "streetwear_", "summer_", "cozy_home_", "smart_casual_", "athleisure_"), + "creator_softcore": ("softcore_creator_compositions", "boudoir_body_compositions"), + "hardcore_all": ("hardcore_",), + "hardcore_explicit": ("hardcore_explicit_compositions",), + "no_outfit_check": (), +} + +COMPOSITION_INLINE_PRESETS = { + "no_outfit_check": [ + "environment-led frame with no outfit-check wording", + "mid-distance scene composition with the room context readable", + "partly occluded candid frame through foreground architecture", + "long perspective frame using repeating background structure", + "waist-up or three-quarter frame without bag, shoes, or footwear emphasis", + ], +} + +THEMATIC_LOCATION_PRESETS = { + "classical_library": { + "locations": [ + {"slug": "classical_large_library", "prompt": "grand classical library hall with towering dark-wood bookshelves, carved columns, rolling ladders, marble floor, warm brass lamps, arched windows, and deep quiet academic atmosphere"}, + {"slug": "old_world_reading_room", "prompt": "large old-world reading room with floor-to-ceiling bookshelves, heavy wooden tables, green banker lamps, leather chairs, tall arched windows, and warm amber evening light"}, + {"slug": "hidden_library_stacks", "prompt": "quiet library stacks with endless tall bookshelves, narrow aisles, rolling ladders, brass lamps, and hidden sightlines between shelves"}, + ], + "compositions": [ + "narrow aisle frame between towering bookshelves", + "over-the-shoulder view through foreground books", + "warm lamp-lit reading-table composition", + "long vanishing-point frame down repeated library stacks", + "partly hidden frame behind carved columns and shelf edges", + ], + }, + "semi_public_affair": { + "locations": [ + {"slug": "hotel_corridor_affair", "prompt": "upscale hotel corridor with repeating numbered doors, patterned carpet, brass wall lamps, luggage carts, and a secluded corner near a service alcove"}, + {"slug": "hotel_service_hall", "prompt": "luxury hotel service corridor with repeating linen carts, beige doors, utility shelves, wall sconces, and a private turn away from the main hallway"}, + {"slug": "parking_garage_hidden", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted floor lines, low fluorescent light, and shadowed blind spots"}, + {"slug": "office_afterhours_affair", "prompt": "empty corporate office after hours with rows of glass partitions, repeating desks, blinds, copier alcove, muted city light, and no visible coworkers"}, + {"slug": "library_stacks_secret", "prompt": "classical library stacks with endless tall bookshelves, narrow aisles, rolling ladders, carved columns, warm brass lamps, and hidden sightlines between shelves"}, + ], + "compositions": [ + "partly concealed frame from behind a doorway edge", + "long corridor vanishing-point composition with repeated doors", + "hidden alcove frame with foreground obstruction", + "surveillance-like candid angle from across the empty space", + "tight frame using pillars, shelves, or walls to block side visibility", + ], + }, + "hotel_corridor": { + "locations": [ + {"slug": "upscale_hotel_corridor", "prompt": "upscale hotel corridor with repeating doors, patterned carpet, brass wall lamps, quiet service alcoves, and warm late-night light"}, + {"slug": "hotel_service_alcove", "prompt": "hotel service alcove with linen carts, beige utility doors, folded towels, soft wall sconces, and a secluded turn off the main corridor"}, + {"slug": "boutique_hotel_stair_landing", "prompt": "boutique hotel stair landing with repeating railings, framed wall panels, low amber lamps, and a quiet corner between floors"}, + ], + "compositions": [ + "long hallway frame with repeated doors receding behind the body", + "corner-alcove composition partly hidden by a wall edge", + "low corridor angle with patterned carpet leading lines", + "over-the-shoulder frame toward a closed hotel-room door", + ], + }, + "parking_garage": { + "locations": [ + {"slug": "empty_parking_garage", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted bay lines, low fluorescent light, and deep shadowed corners"}, + {"slug": "underground_garage_corner", "prompt": "underground parking garage corner with numbered pillars, glossy concrete floor, parked cars, and blue-green fluorescent light"}, + {"slug": "rooftop_parking_deck_night", "prompt": "rooftop parking deck at night with repeated concrete barriers, distant city lights, painted lines, and open wind"}, + ], + "compositions": [ + "pillar-framed composition with repeated concrete columns", + "low angle across painted parking lines", + "hidden corner frame between parked cars", + "wide empty garage frame with strong fluorescent perspective", + ], + }, + "theater_backstage": { + "locations": [ + {"slug": "old_theater_backstage", "prompt": "old theater backstage with repeated velvet curtains, prop racks, costume rails, bulb mirrors, dark wings, and narrow hidden passages"}, + {"slug": "cabaret_backstage_wings", "prompt": "cabaret backstage wings with red curtains, costume racks, vanity bulbs, stage ropes, and warm theatrical shadows"}, + {"slug": "prop_storage_corridor", "prompt": "theater prop storage corridor with stacked trunks, repeated scenery flats, rolling racks, and dim practical lamps"}, + ], + "compositions": [ + "frame between layered velvet curtains", + "backstage mirror-bulb composition with costume racks behind", + "hidden wing angle looking toward the stage light spill", + "narrow prop-aisle frame with repeated vertical flats", + ], + }, + "wine_cellar": { + "locations": [ + {"slug": "private_wine_cellar", "prompt": "private wine cellar with repeating bottle racks, arched brick walls, narrow aisles, dim amber lamps, and secluded corners between shelves"}, + {"slug": "restaurant_wine_storage", "prompt": "restaurant wine storage room with stacked bottle shelves, crate rows, stone floor, soft utility light, and hidden service-door access"}, + {"slug": "arched_cellar_corridor", "prompt": "arched cellar corridor with repeated brick niches, wine racks, low golden lamps, and cool shadowed depth"}, + ], + "compositions": [ + "narrow aisle frame between repeated bottle racks", + "arched brick corridor composition with warm lamps", + "foreground bottle-rack occlusion framing the body", + "low cellar angle with shelves receding behind", + ], + }, + "museum_archive": { + "locations": [ + {"slug": "museum_archive_room", "prompt": "museum archive room with repeating storage shelves, labeled boxes, rolling ladders, long work tables, soft overhead lights, and hidden aisles"}, + {"slug": "gallery_storage_backroom", "prompt": "gallery storage backroom with stacked frames, rolling racks, crate labels, clean concrete floor, and muted work lights"}, + {"slug": "rare_books_archive", "prompt": "rare-books archive with compact shelving, catalog drawers, reading lamps, archival boxes, and narrow private aisles"}, + ], + "compositions": [ + "hidden archive-aisle frame between storage shelves", + "table-edge composition with labeled boxes in the background", + "foreground crate or shelf occlusion", + "long compact-shelving perspective with repeated rows", + ], + }, + "laundromat_late_night": { + "locations": [ + {"slug": "late_night_laundromat", "prompt": "late-night laundromat with repeating washing machines, chrome reflections, tiled floor, fluorescent lights, empty aisles, and a secluded back corner"}, + {"slug": "coin_laundry_back_row", "prompt": "coin laundry back row with stacked dryers, plastic folding tables, detergent shelves, buzzing fluorescent light, and no other customers"}, + {"slug": "laundromat_mirror_windows", "prompt": "quiet laundromat with mirrored machine doors, repeated round windows, tile floor, and cool blue night light through front glass"}, + ], + "compositions": [ + "repeating washer-door perspective behind the body", + "folding-table edge frame with chrome reflections", + "low tiled-floor angle down an empty machine row", + "back-corner composition partly hidden by laundry machines", + ], + }, + "train_station_lockers": { + "locations": [ + {"slug": "train_station_locker_corridor", "prompt": "quiet train-station locker corridor with repeating metal lockers, tiled walls, vending machines, fluorescent light, and a hidden side alcove"}, + {"slug": "empty_platform_underpass", "prompt": "empty station underpass with tiled walls, repeated poster frames, stair railings, fluorescent lights, and late-night quiet"}, + {"slug": "station_service_passage", "prompt": "station service passage with repeating utility doors, metal lockers, warning stripes, and cool overhead light"}, + ], + "compositions": [ + "locker-row vanishing-point composition", + "side-alcove frame partly blocked by metal lockers", + "fluorescent underpass frame with repeated tile lines", + "candid angle from behind a vending machine edge", + ], + }, + "nightclub_back_hall": { + "locations": [ + {"slug": "nightclub_back_hall", "prompt": "nightclub back hallway with black doors, repeated neon strips, coat-check racks, textured walls, and distant colored dance-floor light"}, + {"slug": "club_vip_corridor", "prompt": "VIP club corridor with velvet ropes, mirrored wall panels, low red light, repeated booths, and a private bend in the hallway"}, + {"slug": "music_venue_greenroom_hall", "prompt": "music venue greenroom corridor with stickered doors, cable cases, dim practical lamps, and repeated black curtains"}, + ], + "compositions": [ + "neon hallway frame with repeated dark doors", + "partly hidden VIP-booth angle", + "mirror-panel composition with colored light streaks", + "tight backstage corridor frame with curtains at the edges", + ], + }, + "restaurant_private_booth": { + "locations": [ + {"slug": "restaurant_private_booth", "prompt": "dim restaurant private booth with high banquettes, repeating table lamps, dark wood partitions, folded napkins, and secluded sightlines"}, + {"slug": "empty_bistro_back_corner", "prompt": "empty bistro back corner with tiled floor, small round tables, brass lamps, mirrored walls, and a hidden booth"}, + {"slug": "afterhours_dining_room", "prompt": "after-hours dining room with stacked chairs, repeated tables, low amber sconces, and a quiet service doorway"}, + ], + "compositions": [ + "booth-partition frame with high seat backs blocking the sides", + "table-edge composition with lamps repeating behind", + "mirror-wall restaurant angle with dark wood partitions", + "after-hours dining-room perspective through empty tables", + ], + }, +} + + +def _slug(value: str) -> str: + text = str(value or "").lower() + text = re.sub(r"[^a-z0-9]+", "_", text) + return text.strip("_")[:48] or "custom" + + +def _list_from(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def _unique_extend(target: list[Any], additions: list[Any]) -> None: + seen = set() + for item in target: + try: + seen.add(json.dumps(item, sort_keys=True)) + except TypeError: + seen.add(repr(item)) + for item in additions: + try: + marker = json.dumps(item, sort_keys=True) + except TypeError: + marker = repr(item) + if marker not in seen: + target.append(item) + seen.add(marker) + + +def location_pool_preset_choices() -> list[str]: + pool_choices = [f"pool:{key}" for key in sorted(load_scene_pool_library())] + return list(LOCATION_POOL_PRESETS) + pool_choices + + +def composition_pool_preset_choices() -> list[str]: + pool_choices = [f"pool:{key}" for key in sorted(load_composition_pool_library())] + return list(COMPOSITION_POOL_PRESETS) + pool_choices + + +def location_theme_choices() -> list[str]: + return list(THEMATIC_LOCATION_PRESETS) + + +def location_pool_names_for_preset(preset: str) -> list[str]: + scene_pools = load_scene_pool_library() + preset = str(preset or "custom_only") + if preset.startswith("pool:"): + pool_name = preset.split(":", 1)[1].strip() + return [pool_name] if pool_name in scene_pools else [] + selectors = LOCATION_POOL_PRESETS.get(preset, ()) + names: list[str] = [] + for selector in selectors: + if selector == "*": + _unique_extend(names, sorted(scene_pools)) + elif selector.endswith("_"): + _unique_extend(names, sorted(name for name in scene_pools if name.startswith(selector))) + elif selector in scene_pools: + _unique_extend(names, [selector]) + return names + + +def custom_location_entries(custom_locations: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for raw_line in str(custom_locations or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + slug = "" + prompt = line + if ":" in line: + maybe_slug, maybe_prompt = line.split(":", 1) + if maybe_slug.strip() and maybe_prompt.strip(): + slug = _slug(maybe_slug) + prompt = maybe_prompt.strip() + prompt = prompt.strip() + if prompt: + entries.append({"slug": slug or _slug(prompt), "prompt": prompt}) + return entries + + +def scene_entries_for_pool_names(pool_names: list[str]) -> list[Any]: + scene_pools = load_scene_pool_library() + entries: list[Any] = [] + for pool_name in pool_names: + if pool_name not in scene_pools: + continue + _unique_extend(entries, scene_pools[pool_name]) + return entries + + +def build_location_pool_json( + enabled: bool = True, + combine_mode: str = "replace", + preset: str = "custom_only", + custom_locations: str = "", + location_config: str | dict[str, Any] | None = "", +) -> str: + incoming = parse_location_config(location_config) + combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace" + pool_names = location_pool_names_for_preset(preset) + entries = scene_entries_for_pool_names(pool_names) + _unique_extend(entries, custom_location_entries(custom_locations)) + + if combine_mode == "add" and incoming.get("enabled"): + apply_mode = str(incoming.get("apply_mode") or "replace") + merged_pool_names = _list_from(incoming.get("pool_names")) + _unique_extend(merged_pool_names, pool_names) + merged_entries = _list_from(incoming.get("scene_entries")) + _unique_extend(merged_entries, entries) + else: + apply_mode = "replace" if combine_mode == "replace" else "add" + merged_pool_names = pool_names + merged_entries = entries + + active = bool(enabled) and bool(merged_entries) + summary = ( + f"{apply_mode}; pools={len(merged_pool_names)}; locations={len(merged_entries)}" + if active + else "disabled or empty" + ) + return json.dumps( + { + "enabled": active, + "apply_mode": apply_mode, + "pool_names": merged_pool_names, + "scene_entries": merged_entries, + "summary": summary, + }, + ensure_ascii=True, + sort_keys=True, + ) + + +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": []} + if isinstance(location_config, dict): + raw = dict(location_config) + else: + try: + raw = json.loads(str(location_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid location_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("location_config must be a JSON object") + entries = _list_from(raw.get("scene_entries")) + if not entries and raw.get("pool_names"): + entries = scene_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))]) + return { + "enabled": bool(raw.get("enabled")) and bool(entries), + "apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace", + "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 ""), + } + + +def location_config_active(location_config: dict[str, Any]) -> bool: + return bool(location_config.get("enabled")) and bool(location_config.get("scene_entries")) + + +def composition_pool_names_for_preset(preset: str) -> list[str]: + composition_pools = load_composition_pool_library() + preset = str(preset or "custom_only") + if preset.startswith("pool:"): + pool_name = preset.split(":", 1)[1].strip() + return [pool_name] if pool_name in composition_pools else [] + selectors = COMPOSITION_POOL_PRESETS.get(preset, ()) + names: list[str] = [] + for selector in selectors: + if selector == "*": + _unique_extend(names, sorted(composition_pools)) + elif selector.endswith("_"): + _unique_extend(names, sorted(name for name in composition_pools if name.startswith(selector))) + elif selector in composition_pools: + _unique_extend(names, [selector]) + return names + + +def custom_composition_entries(custom_compositions: str) -> list[str]: + entries: list[str] = [] + for raw_line in str(custom_compositions or "").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + entries.append(line) + return entries + + +def composition_entries_for_pool_names(pool_names: list[str]) -> list[Any]: + composition_pools = load_composition_pool_library() + entries: list[Any] = [] + for pool_name in pool_names: + if pool_name not in composition_pools: + continue + _unique_extend(entries, composition_pools[pool_name]) + return entries + + +def build_composition_pool_json( + enabled: bool = True, + combine_mode: str = "replace", + preset: str = "custom_only", + custom_compositions: str = "", + composition_config: str | dict[str, Any] | None = "", +) -> str: + incoming = parse_composition_config(composition_config) + combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace" + pool_names = composition_pool_names_for_preset(preset) + entries = composition_entries_for_pool_names(pool_names) + _unique_extend(entries, COMPOSITION_INLINE_PRESETS.get(str(preset or ""), [])) + _unique_extend(entries, custom_composition_entries(custom_compositions)) + + if combine_mode == "add" and incoming.get("enabled"): + apply_mode = str(incoming.get("apply_mode") or "replace") + merged_pool_names = _list_from(incoming.get("pool_names")) + _unique_extend(merged_pool_names, pool_names) + merged_entries = _list_from(incoming.get("composition_entries")) + _unique_extend(merged_entries, entries) + else: + apply_mode = "replace" if combine_mode == "replace" else "add" + merged_pool_names = pool_names + merged_entries = entries + + active = bool(enabled) and bool(merged_entries) + summary = ( + f"{apply_mode}; pools={len(merged_pool_names)}; compositions={len(merged_entries)}" + if active + else "disabled or empty" + ) + return json.dumps( + { + "enabled": active, + "apply_mode": apply_mode, + "pool_names": merged_pool_names, + "composition_entries": merged_entries, + "summary": summary, + }, + ensure_ascii=True, + sort_keys=True, + ) + + +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": []} + if isinstance(composition_config, dict): + raw = dict(composition_config) + else: + try: + raw = json.loads(str(composition_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid composition_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("composition_config must be a JSON object") + entries = _list_from(raw.get("composition_entries")) + if not entries and raw.get("pool_names"): + entries = composition_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))]) + return { + "enabled": bool(raw.get("enabled")) and bool(entries), + "apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace", + "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 ""), + } + + +def composition_config_active(composition_config: dict[str, Any]) -> bool: + return bool(composition_config.get("enabled")) and bool(composition_config.get("composition_entries")) + + +def build_thematic_location_json( + enabled: bool = True, + combine_mode: str = "replace", + theme: str = "semi_public_affair", + custom_locations: str = "", + custom_compositions: str = "", + location_config: str | dict[str, Any] | None = "", + composition_config: str | dict[str, Any] | None = "", +) -> tuple[str, str, str]: + theme_data = THEMATIC_LOCATION_PRESETS.get(str(theme or ""), THEMATIC_LOCATION_PRESETS["semi_public_affair"]) + location_lines = "\n".join( + f"{entry['slug']}: {entry['prompt']}" + for entry in theme_data.get("locations", []) + if isinstance(entry, dict) and entry.get("slug") and entry.get("prompt") + ) + if custom_locations.strip(): + location_lines = "\n".join(part for part in (location_lines, custom_locations.strip()) if part) + composition_lines = "\n".join(str(entry) for entry in theme_data.get("compositions", []) if str(entry).strip()) + if custom_compositions.strip(): + composition_lines = "\n".join(part for part in (composition_lines, custom_compositions.strip()) if part) + resolved_location_config = build_location_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset="custom_only", + custom_locations=location_lines, + location_config=location_config or "", + ) + resolved_composition_config = build_composition_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset="custom_only", + 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", "") + summary = f"{theme}; locations={location_summary}; compositions={composition_summary}" + return resolved_location_config, resolved_composition_config, summary + + +_location_pool_names_for_preset = location_pool_names_for_preset +_custom_location_entries = custom_location_entries +_scene_entries_for_pool_names = scene_entries_for_pool_names +_parse_location_config = parse_location_config +_location_config_active = location_config_active +_composition_pool_names_for_preset = composition_pool_names_for_preset +_custom_composition_entries = custom_composition_entries +_composition_entries_for_pool_names = composition_entries_for_pool_names +_parse_composition_config = parse_composition_config +_composition_config_active = composition_config_active diff --git a/node_route_config.py b/node_route_config.py index 748c809..104ce7d 100644 --- a/node_route_config.py +++ b/node_route_config.py @@ -7,29 +7,33 @@ try: from .prompt_builder import ( build_cast_config_json, build_category_config_json, + cast_preset_choices, + category_preset_choices, + subcategory_choices, + ) + from .location_config import ( build_composition_pool_json, build_location_pool_json, build_thematic_location_json, - cast_preset_choices, - category_preset_choices, composition_pool_preset_choices, location_pool_preset_choices, location_theme_choices, - subcategory_choices, ) except ImportError: # Allows local smoke tests from the repository root. from prompt_builder import ( build_cast_config_json, build_category_config_json, + cast_preset_choices, + category_preset_choices, + subcategory_choices, + ) + from location_config import ( build_composition_pool_json, build_location_pool_json, build_thematic_location_json, - cast_preset_choices, - category_preset_choices, composition_pool_preset_choices, location_pool_preset_choices, location_theme_choices, - subcategory_choices, ) diff --git a/prompt_builder.py b/prompt_builder.py index 105264d..84972bd 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -25,6 +25,7 @@ try: ) from . import camera_config as camera_policy from . import generate_prompt_batches as g + from . import location_config as location_policy from . import pair_clothing from . import pair_camera from . import pair_cast @@ -62,6 +63,7 @@ except ImportError: # Allows local smoke tests with `python -c`. ) import camera_config as camera_policy import generate_prompt_batches as g + import location_config as location_policy import pair_clothing import pair_camera import pair_cast @@ -1798,6 +1800,116 @@ def build_thematic_location_json( return resolved_location_config, resolved_composition_config, summary +LOCATION_POOL_PRESETS = location_policy.LOCATION_POOL_PRESETS +COMPOSITION_POOL_PRESETS = location_policy.COMPOSITION_POOL_PRESETS +COMPOSITION_INLINE_PRESETS = location_policy.COMPOSITION_INLINE_PRESETS +THEMATIC_LOCATION_PRESETS = location_policy.THEMATIC_LOCATION_PRESETS + + +def location_pool_preset_choices() -> list[str]: + return location_policy.location_pool_preset_choices() + + +def composition_pool_preset_choices() -> list[str]: + return location_policy.composition_pool_preset_choices() + + +def location_theme_choices() -> list[str]: + return location_policy.location_theme_choices() + + +def _location_pool_names_for_preset(preset: str) -> list[str]: + return location_policy.location_pool_names_for_preset(preset) + + +def _custom_location_entries(custom_locations: str) -> list[dict[str, str]]: + return location_policy.custom_location_entries(custom_locations) + + +def _scene_entries_for_pool_names(pool_names: list[str]) -> list[Any]: + return location_policy.scene_entries_for_pool_names(pool_names) + + +def build_location_pool_json( + enabled: bool = True, + combine_mode: str = "replace", + preset: str = "custom_only", + custom_locations: str = "", + location_config: str | dict[str, Any] | None = "", +) -> str: + return location_policy.build_location_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_locations=custom_locations, + location_config=location_config, + ) + + +def _parse_location_config(location_config: str | dict[str, Any] | None) -> dict[str, Any]: + return location_policy.parse_location_config(location_config) + + +def _location_config_active(location_config: dict[str, Any]) -> bool: + return location_policy.location_config_active(location_config) + + +def _composition_pool_names_for_preset(preset: str) -> list[str]: + return location_policy.composition_pool_names_for_preset(preset) + + +def _custom_composition_entries(custom_compositions: str) -> list[str]: + return location_policy.custom_composition_entries(custom_compositions) + + +def _composition_entries_for_pool_names(pool_names: list[str]) -> list[Any]: + return location_policy.composition_entries_for_pool_names(pool_names) + + +def build_composition_pool_json( + enabled: bool = True, + combine_mode: str = "replace", + preset: str = "custom_only", + custom_compositions: str = "", + composition_config: str | dict[str, Any] | None = "", +) -> str: + return location_policy.build_composition_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_compositions=custom_compositions, + composition_config=composition_config, + ) + + +def _parse_composition_config(composition_config: str | dict[str, Any] | None) -> dict[str, Any]: + return location_policy.parse_composition_config(composition_config) + + +def _composition_config_active(composition_config: dict[str, Any]) -> bool: + return location_policy.composition_config_active(composition_config) + + +def build_thematic_location_json( + enabled: bool = True, + combine_mode: str = "replace", + theme: str = "semi_public_affair", + custom_locations: str = "", + custom_compositions: str = "", + location_config: str | dict[str, Any] | None = "", + composition_config: str | dict[str, Any] | None = "", +) -> tuple[str, str, str]: + return location_policy.build_thematic_location_json( + enabled=enabled, + combine_mode=combine_mode, + theme=theme, + custom_locations=custom_locations, + custom_compositions=custom_compositions, + location_config=location_config, + composition_config=composition_config, + ) + + def _ethnicity_text_from_value(value: Any) -> str: if isinstance(value, dict): return str(value.get("ethnicity") or "").strip() diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 793092f..88bb8e3 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -27,6 +27,7 @@ import caption_naturalizer # noqa: E402 import category_library # noqa: E402 import __init__ as sxcp_nodes # noqa: E402 import krea_formatter # noqa: E402 +import location_config # noqa: E402 import prompt_builder as pb # noqa: E402 import sdxl_formatter # noqa: E402 import seed_config # noqa: E402 @@ -498,6 +499,61 @@ def smoke_config_route_location_theme() -> None: _expect_formatter_outputs(row, "config_route_location_theme", target="single") +def smoke_location_config_policy() -> None: + _expect(pb.LOCATION_POOL_PRESETS is location_config.LOCATION_POOL_PRESETS, "Prompt builder location presets are not delegated") + _expect(pb.COMPOSITION_POOL_PRESETS is location_config.COMPOSITION_POOL_PRESETS, "Prompt builder composition presets are not delegated") + _expect("classical_library" in location_config.location_theme_choices(), "Location themes lost classical_library") + + custom = json.loads( + pb.build_location_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_locations="custom_room: a quiet room with warm lamps", + ) + ) + _expect(custom.get("enabled") is True, "Custom location config should be active") + _expect(custom.get("apply_mode") == "replace", "Custom location config lost replace mode") + _expect(custom.get("scene_entries", [{}])[0].get("slug") == "custom_room", "Custom location slug parser changed") + + added = json.loads( + location_config.build_location_pool_json( + enabled=True, + combine_mode="add", + preset="custom_only", + custom_locations="second_room: another quiet room", + location_config=custom, + ) + ) + _expect(added.get("apply_mode") == "replace", "Location add merge should preserve incoming apply_mode") + _expect(len(added.get("scene_entries") or []) == 2, "Location add merge did not keep both custom locations") + + composition = json.loads( + pb.build_composition_pool_json( + enabled=True, + combine_mode="replace", + preset="no_outfit_check", + custom_compositions="manual frame through foreground bookshelves", + ) + ) + _expect(composition.get("enabled") is True, "Composition config should be active") + _expect( + any("outfit-check" in str(entry) for entry in composition.get("composition_entries") or []), + "Composition inline preset no_outfit_check was not applied", + ) + parsed = pb._parse_location_config({"enabled": True, "pool_names": [], "scene_entries": custom["scene_entries"]}) + _expect(pb._location_config_active(parsed), "Prompt builder location parser wrapper is inactive") + + themed_location, themed_composition, theme_summary = pb.build_thematic_location_json( + enabled=True, + combine_mode="replace", + 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") + + def smoke_category_library_route() -> None: categories = category_library.load_category_library() _expect(len(categories) >= 3, "category library should load JSON categories") @@ -2404,6 +2460,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("builtin_single_woman", smoke_builtin_single), ("camera_scene_single", smoke_camera_scene_single), ("config_route_location_theme", smoke_config_route_location_theme), + ("location_config_policy", smoke_location_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),