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", ], }, "creator_bedroom": { "locations": [ {"slug": "creator_bedroom_ring_light", "prompt": "private creator bedroom with a ring light, phone tripod, rumpled bedding, and warm lamps"}, {"slug": "hotel_bed_phone_tripod", "prompt": "hotel bed content setup with a phone on a mini tripod, city lights, and satin bedding"}, {"slug": "studio_bedroom_backdrop", "prompt": "small creator studio with a bed, seamless backdrop, ring light, and visible phone stand"}, ], "compositions": [ "creator bedroom frame with bed edge and phone tripod readable", "vertical creator-shot frame with ring light and warm lamps behind the body", "bedside content setup composition with bedding and tripod placement visible", "close room-context frame keeping the phone setup and bed plane clear", ], }, "mirror_room": { "locations": [ {"slug": "large_bedroom_mirror_selfie", "prompt": "large bedroom mirror with the phone visible, bed behind the subject, and warm side lamps"}, {"slug": "neon_mirror_wall", "prompt": "neon mirror wall with glossy floor reflections and saturated magenta-blue edge light"}, {"slug": "gold_vanity_mirror", "prompt": "gold-framed vanity mirror with makeup lights, silk fabric, and close reflected framing"}, ], "compositions": [ "mirror-room frame with the reflected phone angle and room depth aligned", "full-length mirror composition keeping reflection lines readable", "vanity-mirror frame with bulbs and reflected body plane visible", "glossy mirror-wall composition with floor reflection line at the lower edge", ], }, "workspace_lounge": { "locations": [ {"slug": "coworking_lounge_window", "prompt": "coworking lounge with tall windows, warm desks, laptop tables, glass partition seams, repeated desk rows, plants, and soft shared-office depth"}, ], "compositions": [ "camera-aware coworking lounge frame with the subjects near a desk edge and tall-window depth behind them", "mid-distance workspace lounge composition with laptop tables and glass partition seams readable around the bodies", "diagonal desk-row frame using repeated work tables, plants, and tall windows for room continuity", "foreground desk-edge composition with the subject dominant and coworking lounge depth still readable", ], }, "boudoir_bedroom": { "locations": [ {"slug": "warm_boudoir_canopy_bed", "prompt": "warm boudoir bedroom with satin sheets, canopy curtains, low lamplight, and bedside phone framing"}, {"slug": "velvet_headboard_bedroom", "prompt": "velvet headboard bedroom with gold lamps, rumpled bedding, and close sensual framing"}, {"slug": "hotel_satin_bedroom", "prompt": "luxury hotel bedroom with satin bedding, city glow, and a visible mirror near the bed"}, ], "compositions": [ "boudoir bedroom frame with sheet folds and warm bedroom depth visible", "bed-edge composition with pillows, lamp glow, and headboard depth", "low bedroom frame using bedding lines as the foreground anchor", "hotel-bed composition with satin sheets and mirror edge readable", ], }, "fetish_studio": { "locations": [ {"slug": "black_latex_studio_floor", "prompt": "dark private studio with glossy black floor reflections, rim light, and a phone tripod"}, {"slug": "red_velvet_lacquer_room", "prompt": "red velvet room with black lacquer furniture, low spotlights, and reflective surfaces"}, {"slug": "chrome_fetish_set", "prompt": "chrome studio set with reflective panels, black curtains, and hard-edged erotic lighting"}, ], "compositions": [ "private studio frame with glossy floor reflection and controlled rim light", "lacquer-room composition with reflective furniture and backdrop depth", "chrome studio frame with panel seams and lighting stands readable", "low studio-floor composition keeping reflection lines and set geometry clear", ], }, "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 entry_prompt_text(value: Any) -> str: if isinstance(value, dict): return str( value.get("prompt") or value.get("template") or value.get("text") or value.get("description") or value.get("name") or "" ).strip() return str(value or "").strip() def json_line_entries(line: str, field_name: str) -> list[Any] | None: line = line.strip() if not line or line[0] not in "[{": return None try: parsed = json.loads(line) except json.JSONDecodeError as exc: raise ValueError(f"Invalid JSON line in {field_name}: {exc}") from exc if isinstance(parsed, list): return parsed return [parsed] def normalize_custom_location_entry(value: Any) -> dict[str, Any]: if isinstance(value, dict): entry = dict(value) prompt = entry_prompt_text(entry) if not prompt: raise ValueError(f"Custom location JSON entry is missing prompt/text/description/name: {value!r}") entry["slug"] = _slug(str(entry.get("slug") or entry.get("name") or prompt)) entry["prompt"] = prompt return entry prompt = str(value or "").strip() if not prompt: raise ValueError("Custom location entry cannot be empty") return {"slug": _slug(prompt), "prompt": prompt} def custom_location_entries(custom_locations: str) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] for raw_line in str(custom_locations or "").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue json_entries = json_line_entries(line, "custom_locations") if json_entries is not None: entries.extend(normalize_custom_location_entry(entry) for entry in json_entries) 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) 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 else "disabled or empty" ) return json.dumps( { "enabled": active, "apply_mode": apply_mode, "pool_names": merged_pool_names, "scene_entries": merged_entries, "summary": summary, "theme": theme, }, 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": [], "theme": ""} 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 ""), "theme": str(raw.get("theme") 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 normalize_custom_composition_entry(value: Any) -> Any: if isinstance(value, dict): entry = dict(value) prompt = entry_prompt_text(entry) if not prompt: raise ValueError(f"Custom composition JSON entry is missing prompt/text/description/name: {value!r}") entry["prompt"] = prompt return entry text = str(value or "").strip() if not text: raise ValueError("Custom composition entry cannot be empty") return text def custom_composition_entries(custom_compositions: str) -> list[Any]: entries: list[Any] = [] for raw_line in str(custom_compositions or "").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue json_entries = json_line_entries(line, "custom_compositions") if json_entries is not None: entries.extend(normalize_custom_composition_entry(entry) for entry in json_entries) 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) 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 else "disabled or empty" ) return json.dumps( { "enabled": active, "apply_mode": apply_mode, "pool_names": merged_pool_names, "composition_entries": merged_entries, "summary": summary, "theme": theme, }, 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": [], "theme": ""} 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 ""), "theme": str(raw.get("theme") 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_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 _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