from __future__ import annotations import json from typing import Any try: from .character_slot import parse_character_cast from .hardcore_position_config import ( build_hardcore_position_pool_json, hardcore_position_family_choices, hardcore_position_key_choices, normalize_hardcore_position_family, normalize_hardcore_position_values, ) from .location_config import build_composition_pool_json, build_location_pool_json except ImportError: # Allows local smoke tests from the repository root. from character_slot import parse_character_cast from hardcore_position_config import ( build_hardcore_position_pool_json, hardcore_position_family_choices, hardcore_position_key_choices, normalize_hardcore_position_family, normalize_hardcore_position_values, ) from location_config import build_composition_pool_json, build_location_pool_json SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG" SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG" SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG" SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST" LOCK_CHOICES = [ "none", "location_from_softcore", "location_from_hardcore", "composition_from_softcore", "composition_from_hardcore", "hardcore_position_current", "softcore_outfit_current", ] WARDROBE_SUBJECTS = ["Woman A", "Man A", "all women", "all men", "all"] def _loads(value: Any) -> dict[str, Any]: if not value: return {} if isinstance(value, dict): return value try: loaded = json.loads(str(value)) except json.JSONDecodeError: return {} return loaded if isinstance(loaded, dict) else {} def _dump(value: Any) -> str: return json.dumps(value, ensure_ascii=True, sort_keys=True) def _text(value: Any) -> str: return str(value or "").strip() def _row(meta: dict[str, Any], key: str) -> dict[str, Any]: value = meta.get(key) return value if isinstance(value, dict) else {} def _first_text(*values: Any) -> str: for value in values: text = _text(value) if text: return text return "" def _first_position_key(row: dict[str, Any], meta: dict[str, Any]) -> str: values: list[Any] = [] if row.get("position_key") is not None: values.append(row.get("position_key")) if row.get("position_keys") is not None: position_keys = row.get("position_keys") values.extend(position_keys if isinstance(position_keys, list) else [position_keys]) config = meta.get("hardcore_position_config") if isinstance(config, dict) and config.get("positions") is not None: positions = config.get("positions") values.extend(positions if isinstance(positions, list) else [positions]) selected = normalize_hardcore_position_values(values) return selected[0] if selected else "" def _position_family(row: dict[str, Any], meta: dict[str, Any]) -> str: config = meta.get("hardcore_position_config") raw_config_family = config.get("family") if isinstance(config, dict) else "" return normalize_hardcore_position_family( _first_text(row.get("position_family"), row.get("source_position_family"), raw_config_family), "any", ) def _choice_rows(meta: dict[str, Any]) -> list[dict[str, Any]]: soft = _row(meta, "softcore_row") hard = _row(meta, "hardcore_row") options = meta.get("options") if isinstance(meta.get("options"), dict) else {} rows = [ ("softcore.level", "softcore", "Softcore Level", options.get("softcore_level")), ("softcore.cast", "softcore", "Softcore Cast", options.get("softcore_cast")), ("softcore.outfit", "softcore", "Softcore Outfit", soft.get("item")), ("softcore.pose", "softcore", "Softcore Pose", soft.get("pose")), ("softcore.expression", "softcore", "Softcore Expression", soft.get("expression")), ("hardcore.level", "hardcore", "Hardcore Level", options.get("hardcore_level")), ("hardcore.cast", "hardcore", "Hardcore Cast", options.get("hardcore_cast")), ("hardcore.position_family", "hardcore", "Position Family", _position_family(hard, meta)), ("hardcore.position_key", "hardcore", "Position Key", _first_position_key(hard, meta)), ("hardcore.role_graph", "hardcore", "Role Graph", hard.get("role_graph")), ("hardcore.action", "hardcore", "Action Text", hard.get("item")), ("hardcore.expression", "hardcore", "Hardcore Expression", hard.get("expression")), ("scene.softcore", "scene", "Softcore Location", soft.get("scene_text")), ("scene.hardcore", "scene", "Hardcore Location", hard.get("scene_text")), ("composition.softcore", "composition", "Softcore Composition", soft.get("composition")), ("composition.hardcore", "composition", "Hardcore Composition", hard.get("composition")), ("camera.softcore", "camera", "Softcore Camera", meta.get("softcore_camera_directive")), ("camera.hardcore", "camera", "Hardcore Camera", meta.get("hardcore_camera_directive")), ("style.softcore", "style", "Softcore Style", soft.get("positive_suffix")), ("style.hardcore", "style", "Hardcore Style", hard.get("positive_suffix")), ] return [ { "axis": axis, "branch": branch, "label": label, "value": _text(value), "active": bool(_text(value)), } for axis, branch, label, value in rows if _text(value) ] def _metadata_slots(meta: dict[str, Any]) -> list[dict[str, Any]]: slots = meta.get("character_cast_slots") if isinstance(slots, list): return parse_character_cast(slots) chain = meta.get("scene_chain") if isinstance(meta.get("scene_chain"), dict) else {} for branch in ("hardcore", "softcore"): branch_scene = chain.get(branch) if isinstance(chain.get(branch), dict) else {} configs = branch_scene.get("configs") if isinstance(branch_scene.get("configs"), dict) else {} parsed = parse_character_cast(configs.get("character_cast")) if parsed: return parsed return [] def _slot_matches_subject(slot: dict[str, Any], subject: str) -> bool: subject = _text(subject).lower() subject_type = _text(slot.get("subject_type")).lower() label = _text(slot.get("label")).upper() if subject == "all": return True if subject == "all women": return subject_type == "woman" if subject == "all men": return subject_type == "man" if subject.startswith("woman "): return subject_type == "woman" and label == subject.split(" ", 1)[1].upper() if subject.startswith("man "): return subject_type == "man" and label == subject.split(" ", 1)[1].upper() return False def _wardrobe_character_cast( meta: dict[str, Any], subject: str, softcore_outfit: str, hardcore_clothing: str, ) -> tuple[str, int]: slots = _metadata_slots(meta) if not slots: return "", 0 changed = 0 for slot in slots: if not _slot_matches_subject(slot, subject): continue if softcore_outfit: slot["softcore_outfit"] = softcore_outfit changed += 1 if hardcore_clothing: slot["hardcore_clothing"] = hardcore_clothing changed += 1 if not changed: return _dump({"profile_type": "character_cast", "version": 1, "slots": slots}), 0 return _dump({"profile_type": "character_cast", "version": 1, "slots": slots}), changed def _lock_location(meta: dict[str, Any], lock_choice: str) -> str: soft = _row(meta, "softcore_row") hard = _row(meta, "hardcore_row") if lock_choice == "location_from_softcore": return _text(soft.get("scene_text")) if lock_choice == "location_from_hardcore": return _text(hard.get("scene_text")) return "" def _lock_composition(meta: dict[str, Any], lock_choice: str) -> str: soft = _row(meta, "softcore_row") hard = _row(meta, "hardcore_row") if lock_choice == "composition_from_softcore": return _text(soft.get("composition")) if lock_choice == "composition_from_hardcore": return _text(hard.get("composition")) return "" def _summary_parts(*parts: str) -> str: clean = [part for part in (_text(part) for part in parts) if part] return "; ".join(clean) if clean else "no overrides active" class SxCPChoiceBoard: OUTPUT_NODE = True @classmethod def INPUT_TYPES(cls): return { "required": { "metadata_json": ("STRING", {"default": "", "multiline": True}), "lock_choice": (LOCK_CHOICES, {"default": "none"}), "location_override": ("STRING", {"default": "", "multiline": True}), "composition_override": ("STRING", {"default": "", "multiline": True}), "hardcore_position_family": (["auto"] + hardcore_position_family_choices(), {"default": "auto"}), "hardcore_position_key": (["auto"] + hardcore_position_key_choices(), {"default": "auto"}), "wardrobe_subject": (WARDROBE_SUBJECTS, {"default": "Woman A"}), "softcore_outfit_override": ("STRING", {"default": "", "multiline": True}), "hardcore_clothing_override": ("STRING", {"default": "", "multiline": True}), }, } RETURN_TYPES = ( SXCP_LOCATION_CONFIG, SXCP_COMPOSITION_CONFIG, SXCP_HARDCORE_POSITION_CONFIG, SXCP_CHARACTER_CAST, "STRING", "STRING", ) RETURN_NAMES = ( "location_config", "composition_config", "hardcore_position_config", "character_cast", "choice_board_json", "summary", ) FUNCTION = "build" CATEGORY = "prompt_builder/v2_scene" def build( self, metadata_json, lock_choice, location_override, composition_override, hardcore_position_family, hardcore_position_key, wardrobe_subject, softcore_outfit_override, hardcore_clothing_override, ): meta = _loads(metadata_json) soft = _row(meta, "softcore_row") hard = _row(meta, "hardcore_row") location_text = _text(location_override) or _lock_location(meta, lock_choice) composition_text = _text(composition_override) or _lock_composition(meta, lock_choice) family = _text(hardcore_position_family) key = _text(hardcore_position_key) if lock_choice == "hardcore_position_current": if family == "auto": family = _position_family(hard, meta) if key == "auto": key = _first_position_key(hard, meta) if family == "auto": family = "any" if key == "auto": key = "" softcore_outfit = _text(softcore_outfit_override) if lock_choice == "softcore_outfit_current" and not softcore_outfit: softcore_outfit = _text(soft.get("item")) hardcore_clothing = _text(hardcore_clothing_override) location_config = ( build_location_pool_json( enabled=True, combine_mode="replace", preset="custom_only", custom_locations=location_text, ) if location_text else "" ) composition_config = ( build_composition_pool_json( enabled=True, combine_mode="replace", preset="custom_only", custom_compositions=composition_text, ) if composition_text else "" ) selected_positions = [key] if key else [] position_active = bool(selected_positions) or family != "any" hardcore_position_config = ( build_hardcore_position_pool_json( combine_mode="replace", family=family, selected_positions=selected_positions, ) if position_active else "" ) character_cast, wardrobe_changes = _wardrobe_character_cast( meta, wardrobe_subject, softcore_outfit, hardcore_clothing, ) if softcore_outfit or hardcore_clothing else ("", 0) board = { "version": 1, "choices": _choice_rows(meta), "overrides": { "lock_choice": lock_choice, "location": location_text, "composition": composition_text, "hardcore_position_family": family if position_active else "", "hardcore_position_key": key, "wardrobe_subject": wardrobe_subject, "softcore_outfit": softcore_outfit, "hardcore_clothing": hardcore_clothing, }, "outputs": { "location_config": bool(location_config), "composition_config": bool(composition_config), "hardcore_position_config": bool(hardcore_position_config), "character_cast": bool(character_cast), "wardrobe_changes": wardrobe_changes, }, } summary = _summary_parts( f"location locked" if location_config else "", f"composition locked" if composition_config else "", f"position locked ({family}{':' + key if key else ''})" if hardcore_position_config else "", f"wardrobe updated x{wardrobe_changes}" if wardrobe_changes else "", ) board_json = _dump(board) return { "ui": { "choice_board_json": [board_json], "summary": [summary], }, "result": ( location_config, composition_config, hardcore_position_config, character_cast, board_json, summary, ), } NODE_CLASS_MAPPINGS = { "SxCPChoiceBoard": SxCPChoiceBoard, } NODE_DISPLAY_NAME_MAPPINGS = { "SxCPChoiceBoard": "SxCP Choice Board", }