Extract location config policy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+10
-6
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user