615 lines
29 KiB
Python
615 lines
29 KiB
Python
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 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
|