From 9434070877fc086ed87ee4f3dc043b80ef8c1593 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 25 Jun 2026 22:37:49 +0200 Subject: [PATCH] Add themed location and composition controls --- README.md | 11 +- __init__.py | 100 +++++++++++ prompt_builder.py | 415 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 522 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b6c8dd9..4b3e795 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,13 @@ node. For cleaner workflows, use the split nodes: - `SxCP Location Pool` outputs `location_config`. `replace` uses only the selected/custom location pool; `add` keeps the category's own locations and adds yours. Custom lines can be plain location text, or `slug: location text`. +- `SxCP Composition Pool` outputs `composition_config` to control framing + separately from location. Use it when category framing mentions unrelated + outfit-check details such as shoes, bags, or mirror poses. +- `SxCP Location Theme` outputs matched `location_config` and + `composition_config`. Themes such as `classical_library`, + `semi_public_affair`, `hotel_corridor`, `parking_garage`, and + `theater_backstage` keep scene and framing compatible. - `SxCP Generation Profile` outputs `generation_profile` for common behavior presets such as casual-clean, evocative-softcore, hardcore-intense, Krea2-friendly, or Flux-original. Its clothing and pose overrides can be @@ -85,8 +92,8 @@ The practical compact workflow is: `Category Preset` + `Cast Control` + `Generation Profile` + optional `Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control` or -`Camera Orbit Control`, `Location Pool`, `Woman Slot` / `Man Slot`, and -`Character Profile` +`Camera Orbit Control`, `Location Theme` or `Location Pool` + `Composition Pool`, +`Woman Slot` / `Man Slot`, and `Character Profile` into `Prompt Builder From Configs`. An importable default workflow is included at diff --git a/__init__.py b/__init__.py index 2e01a6d..7b8e867 100644 --- a/__init__.py +++ b/__init__.py @@ -19,6 +19,7 @@ SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG" SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG" SXCP_CAMERA_CONFIG = "SXCP_CAMERA_CONFIG" SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG" +SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG" SXCP_CATEGORY_CONFIG = "SXCP_CATEGORY_CONFIG" SXCP_CAST_CONFIG = "SXCP_CAST_CONFIG" SXCP_GENERATION_PROFILE = "SXCP_GENERATION_PROFILE" @@ -60,6 +61,7 @@ COMMON_INPUT_TOOLTIPS = { "seed_config": "Per-axis seed config. Connect Global Seed, Seed Locker, or Seed Control here.", "camera_config": "Camera config used by the prompt formatter when camera mode is from_camera_config.", "location_config": "Location config from SxCP Location Pool. It can replace or add to the category scene pool.", + "composition_config": "Composition config from SxCP Composition Pool or Location Theme. It can replace or add framing options.", "softcore_camera_config": "Camera config used only for the softcore Insta/OF prompt. Falls back to camera_config if empty.", "hardcore_camera_config": "Camera config used only for the hardcore Insta/OF prompt. Falls back to camera_config if empty.", "character_profile": "Saved or loaded single-character profile. Character slots override this for configured casts.", @@ -67,6 +69,8 @@ COMMON_INPUT_TOOLTIPS = { "character_slot": "Single slot payload for saving/loading profiles or debugging one character.", "hardcore_position_config": "Hardcore action/position config. Chain Position Pool into Action Filter, then into the generator.", "custom_locations": "One custom location per line. Use plain text, or slug: location text.", + "custom_compositions": "One custom composition/framing phrase per line.", + "theme": "Matched location and composition theme, useful when the place needs compatible framing.", "metadata_json": "Structured metadata from an SxCP generator. Prefer this over raw prompt text for formatters and profile save.", "source_text": "Raw prompt, caption, or metadata JSON depending on input_hint.", "source_text_input": "Optional linked raw prompt/caption input. When connected, it overrides the source_text widget.", @@ -409,6 +413,7 @@ try: build_character_manual_config_json, build_character_profile_json, build_characteristics_config_json, + build_composition_pool_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, @@ -417,6 +422,7 @@ try: build_hardcore_position_pool_json, build_insta_of_options_json, build_location_pool_json, + build_thematic_location_json, build_insta_of_pair, build_prompt, build_prompt_from_configs, @@ -454,6 +460,7 @@ try: character_softcore_outfit_source_choices, character_softcore_outfit_values, character_woman_body_choices, + composition_pool_preset_choices, ethnicity_choices, generation_profile_choices, hardcore_position_family_choices, @@ -461,6 +468,7 @@ try: hardcore_position_key_choices, hardcore_detail_density_choices, load_character_profile_json, + location_theme_choices, location_pool_preset_choices, save_character_profile_payload, seed_mode_choices, @@ -489,6 +497,7 @@ except ImportError: build_character_manual_config_json, build_character_profile_json, build_characteristics_config_json, + build_composition_pool_json, build_ethnicity_list_json, build_filter_config_json, build_generation_profile_json, @@ -497,6 +506,7 @@ except ImportError: build_hardcore_position_pool_json, build_insta_of_options_json, build_location_pool_json, + build_thematic_location_json, build_insta_of_pair, build_prompt, build_prompt_from_configs, @@ -534,6 +544,7 @@ except ImportError: character_softcore_outfit_source_choices, character_softcore_outfit_values, character_woman_body_choices, + composition_pool_preset_choices, ethnicity_choices, generation_profile_choices, hardcore_position_family_choices, @@ -541,6 +552,7 @@ except ImportError: hardcore_position_key_choices, hardcore_detail_density_choices, load_character_profile_json, + location_theme_choices, location_pool_preset_choices, save_character_profile_payload, seed_mode_choices, @@ -654,6 +666,7 @@ class SxCPPromptBuilder: "seed_config": (SXCP_SEED_CONFIG,), "camera_config": (SXCP_CAMERA_CONFIG,), "location_config": (SXCP_LOCATION_CONFIG,), + "composition_config": (SXCP_COMPOSITION_CONFIG,), "character_profile": (SXCP_CHARACTER_PROFILE,), "character_cast": (SXCP_CHARACTER_CAST,), "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), @@ -690,6 +703,7 @@ class SxCPPromptBuilder: seed_config="", camera_config="", location_config="", + composition_config="", character_profile="", character_cast="", hardcore_position_config="", @@ -725,6 +739,7 @@ class SxCPPromptBuilder: seed_config=seed_config or "", camera_config=camera_config or "", location_config=location_config or "", + composition_config=composition_config or "", character_profile=character_profile or "", character_cast=character_cast or "", hardcore_position_config=hardcore_position_config or "", @@ -1204,6 +1219,81 @@ class SxCPLocationPool: return config, parsed.get("summary", "") +class SxCPCompositionPool: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "preset": (composition_pool_preset_choices(), {"default": "no_outfit_check"}), + "custom_compositions": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "composition_config": (SXCP_COMPOSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_COMPOSITION_CONFIG, "STRING") + RETURN_NAMES = ("composition_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, enabled, combine_mode, preset, custom_compositions, composition_config=""): + config = build_composition_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_compositions=custom_compositions or "", + composition_config=composition_config or "", + ) + parsed = json.loads(config) + return config, parsed.get("summary", "") + + +class SxCPLocationTheme: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "theme": (location_theme_choices(), {"default": "semi_public_affair"}), + "custom_locations": ("STRING", {"default": "", "multiline": True}), + "custom_compositions": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "location_config": (SXCP_LOCATION_CONFIG,), + "composition_config": (SXCP_COMPOSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_LOCATION_CONFIG, SXCP_COMPOSITION_CONFIG, "STRING") + RETURN_NAMES = ("location_config", "composition_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + enabled, + combine_mode, + theme, + custom_locations, + custom_compositions, + location_config="", + composition_config="", + ): + return build_thematic_location_json( + enabled=enabled, + combine_mode=combine_mode, + theme=theme, + custom_locations=custom_locations or "", + custom_compositions=custom_compositions or "", + location_config=location_config or "", + composition_config=composition_config or "", + ) + + class SxCPCastControl: @classmethod def INPUT_TYPES(cls): @@ -1956,6 +2046,7 @@ class SxCPPromptBuilderFromConfigs: "seed_config": (SXCP_SEED_CONFIG,), "camera_config": (SXCP_CAMERA_CONFIG,), "location_config": (SXCP_LOCATION_CONFIG,), + "composition_config": (SXCP_COMPOSITION_CONFIG,), "character_profile": (SXCP_CHARACTER_PROFILE,), "character_cast": (SXCP_CHARACTER_CAST,), "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), @@ -1982,6 +2073,7 @@ class SxCPPromptBuilderFromConfigs: seed_config="", camera_config="", location_config="", + composition_config="", character_profile="", character_cast="", hardcore_position_config="", @@ -1999,6 +2091,7 @@ class SxCPPromptBuilderFromConfigs: seed_config=seed_config or "", camera_config=camera_config or "", location_config=location_config or "", + composition_config=composition_config or "", character_profile=character_profile or "", character_cast=character_cast or "", hardcore_position_config=hardcore_position_config or "", @@ -2720,6 +2813,7 @@ class SxCPInstaOFPromptPair: "softcore_camera_config": (SXCP_CAMERA_CONFIG,), "hardcore_camera_config": (SXCP_CAMERA_CONFIG,), "location_config": (SXCP_LOCATION_CONFIG,), + "composition_config": (SXCP_COMPOSITION_CONFIG,), "character_profile": (SXCP_CHARACTER_PROFILE,), "character_cast": (SXCP_CHARACTER_CAST,), "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), @@ -2759,6 +2853,7 @@ class SxCPInstaOFPromptPair: softcore_camera_config="", hardcore_camera_config="", location_config="", + composition_config="", character_profile="", character_cast="", hardcore_position_config="", @@ -2784,6 +2879,7 @@ class SxCPInstaOFPromptPair: softcore_camera_config=softcore_camera_config or "", hardcore_camera_config=hardcore_camera_config or "", location_config=location_config or "", + composition_config=composition_config or "", character_profile=character_profile or "", character_cast=character_cast or "", hardcore_position_config=hardcore_position_config or "", @@ -2813,6 +2909,8 @@ NODE_CLASS_MAPPINGS = { "SxCPQwenCameraTranslator": SxCPQwenCameraTranslator, "SxCPCategoryPreset": SxCPCategoryPreset, "SxCPLocationPool": SxCPLocationPool, + "SxCPCompositionPool": SxCPCompositionPool, + "SxCPLocationTheme": SxCPLocationTheme, "SxCPCastControl": SxCPCastControl, "SxCPCastBias": SxCPCastBias, "SxCPGenerationProfile": SxCPGenerationProfile, @@ -2856,6 +2954,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator", "SxCPCategoryPreset": "SxCP Category Preset", "SxCPLocationPool": "SxCP Location Pool", + "SxCPCompositionPool": "SxCP Composition Pool", + "SxCPLocationTheme": "SxCP Location Theme", "SxCPCastControl": "SxCP Cast Control", "SxCPCastBias": "SxCP Cast Bias", "SxCPGenerationProfile": "SxCP Generation Profile", diff --git a/prompt_builder.py b/prompt_builder.py index a3bb9b1..742bca2 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1348,6 +1348,188 @@ def load_composition_pool_library() -> dict[str, list[Any]]: return _load_named_pool_library("composition_pools") +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", + ], +} + + +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 + + +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 location_theme_choices() -> list[str]: + return list(THEMATIC_LOCATION_PRESETS) + + def _extension_targets() -> dict[str, tuple[list[Any], bool]]: return { "women_clothes": (g.WOMEN_CLOTHES, False), @@ -1824,6 +2006,156 @@ 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 + + def _ethnicity_text_from_value(value: Any) -> str: if isinstance(value, dict): return str(value.get("ethnicity") or "").strip() @@ -5857,6 +6189,50 @@ def _apply_location_config_to_legacy_row( return row +def _legacy_composition_entries_for_row(row: dict[str, Any]) -> list[Any]: + subject = str(row.get("primary_subject") or "").lower() + if "group" in subject or "layout" in subject: + return list(g.GROUP_COMPOSITIONS) + return list(g.COMPOSITIONS) + + +def _apply_composition_config_to_legacy_row( + row: dict[str, Any], + composition_config: dict[str, Any], + seed_config: dict[str, int], + seed: int, + row_number: int, +) -> dict[str, Any]: + if not _composition_config_active(composition_config): + return row + composition_entries = _list_from(composition_config.get("composition_entries")) + if composition_config.get("apply_mode") == "add": + choices = _legacy_composition_entries_for_row(row) + _unique_extend(choices, composition_entries) + else: + choices = composition_entries + composition_rng = _axis_rng(seed_config, "composition", seed, row_number) + new_composition = _choose_text(composition_rng, choices) + old_composition = str(row.get("composition") or "") + old_prompt_fragment = f"Composition: vertical {old_composition}." + new_prompt_fragment = f"Composition: {_composition_prompt(new_composition)}." + row["source_composition"] = old_composition + row["composition"] = new_composition + row["composition_prompt"] = _composition_prompt(new_composition) + row["composition_config"] = composition_config + if old_composition: + row["prompt"] = str(row.get("prompt") or "").replace(old_prompt_fragment, new_prompt_fragment) + row["caption"] = str(row.get("caption") or "").replace(f", {old_composition},", f", {new_composition},") + else: + row["prompt"] = re.sub( + r"Composition:\s*.*?\.\s*Use", + f"{new_prompt_fragment} Use", + str(row.get("prompt") or ""), + count=1, + ) + return row + + def _sources_with_inheritance( category: dict[str, Any], subcategory: dict[str, Any], @@ -6042,7 +6418,17 @@ def _pose_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any, return g.EVOCATIVE_ALL if poses == "evocative" else g.POSES -def _composition_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any, subject_type: str) -> list[Any]: +def _composition_pool( + category: dict[str, Any], + subcategory: dict[str, Any], + item: Any, + subject_type: str, + composition_config: dict[str, Any] | None = None, +) -> list[Any]: + composition_config = composition_config or {} + composition_entries = _list_from(composition_config.get("composition_entries")) + if _composition_config_active(composition_config) and composition_config.get("apply_mode") == "replace": + return composition_entries configured = _configured_pool( category, subcategory, @@ -6052,6 +6438,9 @@ def _composition_pool(category: dict[str, Any], subcategory: dict[str, Any], ite load_composition_pool_library(), "inherit_compositions", ) + if _composition_config_active(composition_config) and composition_config.get("apply_mode") == "add": + configured = list(configured or []) + _unique_extend(configured, composition_entries) if configured: return configured if subject_type in ("group", "configured_cast"): @@ -6083,6 +6472,7 @@ def _build_custom_row( expression_phase: str = "", hardcore_position_config: str | dict[str, Any] | None = None, location_config: str | dict[str, Any] | None = None, + composition_config: str | dict[str, Any] | None = None, ) -> dict[str, Any]: categories = load_category_library() category_rng = _axis_rng(seed_config, "category", seed, row_number) @@ -6095,6 +6485,7 @@ def _build_custom_row( composition_rng = _axis_rng(seed_config, "composition", seed, row_number) parsed_hardcore_position_config = _parse_hardcore_position_config(hardcore_position_config) parsed_location_config = _parse_location_config(location_config) + parsed_composition_config = _parse_composition_config(composition_config) requested_women_count = women_count requested_men_count = men_count @@ -6259,7 +6650,11 @@ def _build_custom_row( expression = character_expression_text source_composition = _choose_text( composition_rng, - _compatible_entries(_composition_pool(category, subcategory, item, subject_type), women_count, men_count), + _compatible_entries( + _composition_pool(category, subcategory, item, subject_type, parsed_composition_config), + women_count, + men_count, + ), ) if is_pose_category: source_composition = _sanitize_hardcore_environment_anchors(source_composition) @@ -6302,6 +6697,7 @@ def _build_custom_row( "composition": composition, "source_composition": source_composition, "composition_prompt": _composition_prompt(composition), + "composition_config": parsed_composition_config if _composition_config_active(parsed_composition_config) else {}, "role_graph": role_graph, "source_role_graph": source_role_graph, "pov_character_labels": pov_character_labels, @@ -6441,6 +6837,7 @@ def build_prompt( expression_phase: str = "", hardcore_position_config: str | dict[str, Any] | None = None, location_config: str | dict[str, Any] | None = None, + composition_config: str | dict[str, Any] | None = None, ) -> dict[str, Any]: apply_pool_extensions() row_number = max(1, int(row_number)) @@ -6452,6 +6849,7 @@ def build_prompt( pose_ratio = _ratio_or_none(standard_pose_ratio) parsed_seed_config = _parse_seed_config(seed_config) parsed_location_config = _parse_location_config(location_config) + parsed_composition_config = _parse_composition_config(composition_config) content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number) pose_axis_rng = _axis_rng(parsed_seed_config, "pose", seed, row_number) person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number) @@ -6525,6 +6923,7 @@ def build_prompt( expression_phase, hardcore_position_config, parsed_location_config, + parsed_composition_config, ) if row.get("source") == "built_in_generator": @@ -6535,6 +6934,13 @@ def build_prompt( seed, row_number, ) + row = _apply_composition_config_to_legacy_row( + row, + parsed_composition_config, + parsed_seed_config, + seed, + row_number, + ) if not expression_enabled: row = _disable_row_expression(row, "disabled") if extra_positive.strip(): @@ -6563,6 +6969,7 @@ def build_prompt_from_configs( character_cast: str | dict[str, Any] | list[Any] | None = "", hardcore_position_config: str | dict[str, Any] | None = "", location_config: str | dict[str, Any] | None = "", + composition_config: str | dict[str, Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -6599,6 +7006,7 @@ def build_prompt_from_configs( character_cast=character_cast or "", hardcore_position_config=hardcore_position_config or "", location_config=location_config or "", + composition_config=composition_config or "", ) @@ -7382,6 +7790,7 @@ def build_insta_of_pair( character_cast: str | dict[str, Any] | list[Any] | None = "", hardcore_position_config: str | dict[str, Any] | None = "", location_config: str | dict[str, Any] | None = "", + composition_config: str | dict[str, Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -7460,6 +7869,7 @@ def build_insta_of_pair( character_profile="" if primary_slot else character_profile or "", character_cast="", location_config=location_config or "", + composition_config=composition_config or "", ) soft_row["expression_intensity_source"] = soft_expression_intensity_source if primary_slot_context: @@ -7518,6 +7928,7 @@ def build_insta_of_pair( expression_phase="hardcore", hardcore_position_config=hardcore_position_config or "", location_config=location_config or "", + composition_config=composition_config or "", ) hard_row["hardcore_detail_density"] = options["hardcore_detail_density"] hard_row["pov_character_labels"] = pov_character_labels