diff --git a/README.md b/README.md index 7e758ef..d4afa86 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,23 @@ The node is registered as: - `prompt_builder / SxCP Krea2 Formatter` - `prompt_builder / SxCP Insta/OF Options` - `prompt_builder / SxCP Insta/OF Prompt Pair` +- `prompt_builder / v2_scene / SxCP Scene Start` +- `prompt_builder / v2_scene / SxCP Scene Cast` +- `prompt_builder / v2_scene / SxCP Scene Character` +- `prompt_builder / v2_scene / SxCP Scene Wardrobe` +- `prompt_builder / v2_scene / SxCP Scene Location` +- `prompt_builder / v2_scene / SxCP Scene Set Dressing` +- `prompt_builder / v2_scene / SxCP Scene Blocking` +- `prompt_builder / v2_scene / SxCP Scene Action` +- `prompt_builder / v2_scene / SxCP Scene Performance` +- `prompt_builder / v2_scene / SxCP Scene Camera` +- `prompt_builder / v2_scene / SxCP Scene Composition` +- `prompt_builder / v2_scene / SxCP Scene Lighting` +- `prompt_builder / v2_scene / SxCP Scene Branch Pair` +- `prompt_builder / v2_scene / SxCP Softcore Branch Options` +- `prompt_builder / v2_scene / SxCP Hardcore Branch Options` +- `prompt_builder / v2_scene / SxCP Scene Output` +- `prompt_builder / v2_scene / SxCP Scene Pair Output` It outputs: @@ -101,6 +118,20 @@ The practical compact workflow is: `Woman Slot` / `Man Slot`, and `Character Profile` into `Prompt Builder From Configs`. +## Scene-Chain v2 Nodes + +The v2 scene nodes are an additive workflow surface. They pass one structured +`SXCP_SCENE` object through cast, character, wardrobe, location, set dressing, +blocking, action, performance, camera, composition, and lighting layers. Use +`SxCP Scene Output` for a single prompt, or split a shared scene with +`SxCP Scene Branch Pair`, refine it with `SxCP Softcore Branch Options` and +`SxCP Hardcore Branch Options`, then render both sides through +`SxCP Scene Pair Output`. + +The current v2 output nodes intentionally reuse the existing builder, +Insta/OF pair, and formatter metadata routes. This keeps old workflows working +while giving new workflows a cleaner movie-scene structure. + An importable default workflow is included at `examples/default_task_lanes_workflow.json`. It is laid out by task instead of as one long chain: diff --git a/__init__.py b/__init__.py index 729e429..5d6678f 100644 --- a/__init__.py +++ b/__init__.py @@ -24,6 +24,7 @@ SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG" SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST" SXCP_CHARACTER_SLOT = "SXCP_CHARACTER_SLOT" SXCP_CHARACTER_PROFILE = "SXCP_CHARACTER_PROFILE" +SXCP_SCENE = "SXCP_SCENE" try: from .node_tooltips import install_input_tooltips as _install_input_tooltips @@ -72,6 +73,10 @@ try: NODE_CLASS_MAPPINGS as SEED_RESOLUTION_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, ) + from .node_scene import ( + NODE_CLASS_MAPPINGS as SCENE_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as SCENE_NODE_DISPLAY_NAME_MAPPINGS, + ) from .server_routes import ( accumulator_delete_payload, accumulator_list_payload, @@ -120,6 +125,10 @@ except ImportError: NODE_CLASS_MAPPINGS as SEED_RESOLUTION_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, ) + from node_scene import ( + NODE_CLASS_MAPPINGS as SCENE_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as SCENE_NODE_DISPLAY_NAME_MAPPINGS, + ) from server_routes import ( accumulator_delete_payload, accumulator_list_payload, @@ -186,6 +195,7 @@ NODE_CLASS_MAPPINGS.update(FORMATTER_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(INSTA_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(ROUTE_CONFIG_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(PROFILE_FILTER_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(SCENE_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(LOOP_NODE_CLASS_MAPPINGS) _install_input_tooltips(NODE_CLASS_MAPPINGS) @@ -199,6 +209,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(FORMATTER_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(INSTA_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(ROUTE_CONFIG_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(PROFILE_FILTER_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(SCENE_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(LOOP_NODE_DISPLAY_NAME_MAPPINGS) WEB_DIRECTORY = "./web" diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 5406194..a76cfc3 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -68,10 +68,18 @@ cleanup such as clothing/body-access scene sanitization. | `SxCP Prompt Builder` | `build_prompt` -> `builder_prompt_route.py` | Direct single prompt generation. Can use built-in categories or JSON categories. | | `SxCP Prompt Builder From Configs` | `build_prompt_from_configs` -> `builder_config_route.py` -> `build_prompt` -> `builder_prompt_route.py` | Same generator, but inputs come from category/cast/profile/filter helper nodes. | | `SxCP Insta/OF Prompt Pair` | `build_insta_of_pair` | Builds a softcore row and hardcore row with shared cast/continuity options. | +| `SxCP Scene Start` / `SxCP Scene Output` / `SxCP Scene Pair Output` | `node_scene.py` -> existing builder and pair routes | v2 structured `SXCP_SCENE` chain. Layers are split into cast, character, wardrobe, location, set dressing, blocking, action, performance, camera, composition, and lighting before compatibility rendering. | | `SxCP Krea2 Formatter` | `format_krea2_prompt` -> `krea_format_route.py` | Converts metadata rows or pair metadata into Krea2-friendly prose. | | `SxCP SDXL Formatter` | `format_sdxl_prompt` -> `sdxl_format_route.py` | Converts metadata rows or pair metadata into SDXL/tag style prompts. | | `SxCP Caption Naturalizer` | `naturalize_caption` -> `caption_format_route.py` | Converts rows into more natural sentence captions. | +V2 scene-chain display nodes: `SxCP Scene Cast`, `SxCP Scene Character`, +`SxCP Scene Wardrobe`, `SxCP Scene Location`, `SxCP Scene Set Dressing`, +`SxCP Scene Blocking`, `SxCP Scene Action`, `SxCP Scene Performance`, +`SxCP Scene Camera`, `SxCP Scene Composition`, `SxCP Scene Lighting`, +`SxCP Scene Branch Pair`, `SxCP Softcore Branch Options`, and +`SxCP Hardcore Branch Options`. + Core helper ownership: | Python module | What it owns | diff --git a/node_scene.py b/node_scene.py new file mode 100644 index 0000000..7a1afe8 --- /dev/null +++ b/node_scene.py @@ -0,0 +1,1262 @@ +from __future__ import annotations + +import copy +import json +from typing import Any + +try: + from .prompt_builder import ( + INSTA_OF_HARDCORE_CLOTHING_CONTINUITY, + INSTA_OF_HARDCORE_LEVELS, + INSTA_OF_PLATFORM_STYLES, + INSTA_OF_SOFT_LEVELS, + build_camera_config_json, + build_cast_config_json, + build_category_config_json, + build_character_slot_json, + build_composition_pool_json, + build_generation_profile_json, + build_insta_of_options_json, + build_insta_of_pair, + build_location_pool_json, + build_prompt_from_configs, + camera_angle_choices, + camera_detail_choices, + camera_distance_choices, + camera_lens_choices, + camera_mode_choices, + camera_orientation_choices, + camera_phone_choices, + camera_priority_choices, + camera_shot_choices, + cast_preset_choices, + category_preset_choices, + character_age_choices, + character_body_choices, + character_descriptor_detail_choices, + character_ethnicity_choices, + character_figure_choices, + character_label_choices, + character_presence_choices, + composition_pool_preset_choices, + generation_profile_choices, + hardcore_detail_density_choices, + location_pool_preset_choices, + subcategory_choices, + ) +except ImportError: # Allows local smoke tests from the repository root. + from prompt_builder import ( + INSTA_OF_HARDCORE_CLOTHING_CONTINUITY, + INSTA_OF_HARDCORE_LEVELS, + INSTA_OF_PLATFORM_STYLES, + INSTA_OF_SOFT_LEVELS, + build_camera_config_json, + build_cast_config_json, + build_category_config_json, + build_character_slot_json, + build_composition_pool_json, + build_generation_profile_json, + build_insta_of_options_json, + build_insta_of_pair, + build_location_pool_json, + build_prompt_from_configs, + camera_angle_choices, + camera_detail_choices, + camera_distance_choices, + camera_lens_choices, + camera_mode_choices, + camera_orientation_choices, + camera_phone_choices, + camera_priority_choices, + camera_shot_choices, + cast_preset_choices, + category_preset_choices, + character_age_choices, + character_body_choices, + character_descriptor_detail_choices, + character_ethnicity_choices, + character_figure_choices, + character_label_choices, + character_presence_choices, + composition_pool_preset_choices, + generation_profile_choices, + hardcore_detail_density_choices, + location_pool_preset_choices, + subcategory_choices, + ) + + +SXCP_SCENE = "SXCP_SCENE" +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" +SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG" +SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST" +SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST" +SXCP_CHARACTER_SLOT = "SXCP_CHARACTER_SLOT" +SXCP_CHARACTER_MANUAL = "SXCP_CHARACTER_MANUAL" +SXCP_CHARACTERISTICS = "SXCP_CHARACTERISTICS" +SXCP_HAIR_CONFIG = "SXCP_HAIR_CONFIG" +SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG" + +SCENE_SCHEMA = "sxcp_scene_v2" +TARGET_FORMATTERS = ["raw", "krea2", "sdxl", "caption"] +SCENE_KINDS = ["regular", "softcore", "hardcore"] +CENTRAL_SUBJECT_CHOICES = ["auto", "woman_a", "man_a", "none"] +POV_PARTICIPANT_CHOICES = ["none", "man_a"] +SUBJECT_LABEL_CHOICES = ["all"] + [choice for choice in character_label_choices() if choice != "auto_chain"] +BRANCH_NAMES = ("softcore", "hardcore") +SOFTCORE_CAMERA_CHOICES = ["from_camera_config"] + camera_mode_choices() +HARDCORE_CAMERA_CHOICES = ["from_camera_config", "same_as_softcore"] + camera_mode_choices() +HARDCORE_CLOTHING_CONTINUITY_CHOICES = list(INSTA_OF_HARDCORE_CLOTHING_CONTINUITY) + + +def _json_dict(value: str | dict[str, Any] | None) -> dict[str, Any]: + if not value: + return {} + if isinstance(value, dict): + return copy.deepcopy(value) + try: + parsed = json.loads(str(value)) + except json.JSONDecodeError: + return {} + return copy.deepcopy(parsed) if isinstance(parsed, dict) else {} + + +def _dump(value: dict[str, Any]) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True) + + +def _parse_scene(scene: str | dict[str, Any] | None) -> dict[str, Any]: + parsed = _json_dict(scene) + if parsed.get("schema") != SCENE_SCHEMA: + parsed = {} + parsed.setdefault("schema", SCENE_SCHEMA) + parsed.setdefault("version", 1) + parsed.setdefault("row_number", 1) + parsed.setdefault("start_index", 41) + parsed.setdefault("seed", 20260614) + parsed.setdefault("target_formatter", "raw") + parsed.setdefault("trigger", "sxcpinup_coloredpencil") + parsed.setdefault("prepend_trigger_to_prompt", True) + parsed.setdefault("extra_positive", "") + parsed.setdefault("extra_negative", "") + parsed.setdefault("configs", {}) + parsed.setdefault("layers", {}) + parsed.setdefault("branches", {}) + parsed.setdefault("history", []) + return parsed + + +def _scene_out(scene: dict[str, Any]) -> tuple[str, str, str]: + summary = _scene_summary(scene) + return _dump(scene), summary, _dump(scene) + + +def _scene_summary(scene: dict[str, Any]) -> str: + layers = ",".join(sorted(scene.get("layers", {}))) or "empty" + branches = ",".join(sorted(scene.get("branches", {}))) or "none" + return ( + f"scene v{scene.get('version', 1)}; row={scene.get('row_number')}; " + f"seed={scene.get('seed')}; layers={layers}; branches={branches}" + ) + + +def _add_history(scene: dict[str, Any], node: str, summary: str) -> None: + history = scene.setdefault("history", []) + if isinstance(history, list): + history.append({"node": node, "summary": summary}) + + +def _set_config(scene: dict[str, Any], key: str, value: Any) -> None: + if value not in (None, ""): + scene.setdefault("configs", {})[key] = value + + +def _set_layer(scene: dict[str, Any], layer: str, values: dict[str, Any], summary: str) -> None: + scene.setdefault("layers", {})[layer] = values + _add_history(scene, layer, summary) + + +def _branch(scene: dict[str, Any], name: str) -> dict[str, Any]: + branches = scene.setdefault("branches", {}) + branch = branches.setdefault(name, {}) + branch.setdefault("configs", {}) + branch.setdefault("options", {}) + branch.setdefault("extra_positive", "") + return branch + + +def _text_parts(*values: Any) -> list[str]: + parts: list[str] = [] + for value in values: + text = str(value or "").strip() + if text and text not in parts: + parts.append(text) + return parts + + +def _joined_text(*values: Any) -> str: + return ". ".join(part.rstrip(".") for part in _text_parts(*values)) + + +def _cast_slots(character_cast: Any) -> list[dict[str, Any]]: + cast = _json_dict(character_cast) + slots = cast.get("slots") + if isinstance(slots, list): + return [dict(slot) for slot in slots if isinstance(slot, dict)] + if isinstance(character_cast, list): + return [dict(slot) for slot in character_cast if isinstance(slot, dict)] + return [] + + +def _slot_matches(slot: dict[str, Any], subject_label: str) -> bool: + if subject_label == "all": + return True + return str(slot.get("label") or "").strip().upper() == subject_label.upper() + + +def _update_character_cast_wardrobe( + character_cast: str | dict[str, Any] | list[Any] | None, + subject_label: str, + softcore_outfit: str, + hardcore_clothing: str, +) -> str: + slots = _cast_slots(character_cast) + if not slots: + return character_cast if isinstance(character_cast, str) else "" + changed = False + for slot in slots: + if not _slot_matches(slot, subject_label): + continue + if softcore_outfit: + slot["softcore_outfit"] = softcore_outfit + changed = True + if hardcore_clothing: + slot["hardcore_clothing"] = hardcore_clothing + changed = True + if not changed: + return character_cast if isinstance(character_cast, str) else _dump({"profile_type": "character_cast", "version": 1, "slots": slots}) + return _dump({"profile_type": "character_cast", "version": 1, "slots": slots}) + + +def _base_config(scene: dict[str, Any], key: str, default: str = "") -> str: + return str((scene.get("configs") or {}).get(key) or default or "") + + +def _build_generation_profile_from_scene(scene: dict[str, Any]) -> str: + existing = _base_config(scene, "generation_profile") + if existing: + profile_config = _json_dict(existing) + if profile_config: + profile_config["trigger"] = str(scene.get("trigger") or profile_config.get("trigger") or "sxcpinup_coloredpencil") + profile_config["prepend_trigger_to_prompt"] = bool(scene.get("prepend_trigger_to_prompt", True)) + return _dump(profile_config) + return existing + start_profile = str(scene.get("profile") or "balanced") + wardrobe = scene.get("layers", {}).get("wardrobe", {}) + performance = scene.get("layers", {}).get("performance", {}) + clothing_override = str(wardrobe.get("clothing_override") or "profile_default") + expression_enabled = bool(performance.get("expression_enabled", True)) + expression_mode = str(performance.get("expression_intensity_mode") or "profile_default") + expression_intensity = float(performance.get("expression_intensity", -1.0)) + trigger_policy = "prepend_trigger" if scene.get("prepend_trigger_to_prompt", True) else "do_not_prepend" + profile_json = build_generation_profile_json( + profile=start_profile, + clothing_override=clothing_override, + expression_enabled=expression_enabled, + expression_intensity_mode=expression_mode, + expression_intensity=expression_intensity, + trigger_policy=trigger_policy, + ) + profile_config = _json_dict(profile_json) + profile_config["trigger"] = str(scene.get("trigger") or profile_config.get("trigger") or "sxcpinup_coloredpencil") + profile_config["prepend_trigger_to_prompt"] = bool(scene.get("prepend_trigger_to_prompt", True)) + return _dump(profile_config) + + +def _scene_extra_positive(scene: dict[str, Any], branch_name: str = "") -> str: + layers = scene.get("layers", {}) + branch = scene.get("branches", {}).get(branch_name, {}) if branch_name else {} + parts = _text_parts( + scene.get("extra_positive"), + layers.get("set_dressing", {}).get("prompt"), + layers.get("blocking", {}).get("prompt"), + layers.get("action", {}).get("prompt"), + layers.get("performance", {}).get("prompt"), + layers.get("lighting", {}).get("prompt"), + branch.get("extra_positive"), + ) + return ". ".join(part.rstrip(".") for part in parts) + + +def _compat_configs(scene: dict[str, Any], branch_name: str = "") -> dict[str, Any]: + configs = scene.get("configs") or {} + branch = scene.get("branches", {}).get(branch_name, {}) if branch_name else {} + branch_configs = branch.get("configs") or {} + category_config = branch_configs.get("category_config") or configs.get("category_config") + if not category_config: + category_config = build_category_config_json( + preset=str(scene.get("category_preset") or "auto_weighted"), + subcategory=str(scene.get("subcategory") or "random"), + ) + cast_config = branch_configs.get("cast_config") or configs.get("cast_config") + if not cast_config: + cast = scene.get("layers", {}).get("cast", {}) + cast_config = build_cast_config_json( + cast_mode=str(cast.get("cast_mode") or "mixed_couple"), + women_count=int(cast.get("women_count", 1)), + men_count=int(cast.get("men_count", 1)), + ) + return { + "category_config": category_config, + "cast_config": cast_config, + "generation_profile": branch_configs.get("generation_profile") or _build_generation_profile_from_scene(scene), + "filter_config": branch_configs.get("filter_config") or configs.get("filter_config") or "", + "seed_config": branch_configs.get("seed_config") or configs.get("seed_config") or scene.get("seed_config") or "", + "camera_config": branch_configs.get("camera_config") or configs.get("camera_config") or "", + "location_config": branch_configs.get("location_config") or configs.get("location_config") or "", + "composition_config": branch_configs.get("composition_config") or configs.get("composition_config") or "", + "character_profile": branch_configs.get("character_profile") or configs.get("character_profile") or "", + "character_cast": branch_configs.get("character_cast") or configs.get("character_cast") or "", + "hardcore_position_config": branch_configs.get("hardcore_position_config") or configs.get("hardcore_position_config") or "", + "extra_positive": _scene_extra_positive(scene, branch_name), + "extra_negative": str(scene.get("extra_negative") or ""), + } + + +def _pair_options(soft_scene: dict[str, Any], hard_scene: dict[str, Any]) -> str: + soft_pair = soft_scene.get("pair", {}) + hard_pair = hard_scene.get("pair", {}) + soft_options = soft_scene.get("branches", {}).get("softcore", {}).get("options", {}) + hard_options = hard_scene.get("branches", {}).get("hardcore", {}).get("options", {}) + return build_insta_of_options_json( + softcore_cast=str(soft_options.get("softcore_cast") or "solo"), + hardcore_cast=str(hard_options.get("hardcore_cast") or "use_counts"), + hardcore_women_count=int(hard_options.get("hardcore_women_count", 1)), + hardcore_men_count=int(hard_options.get("hardcore_men_count", 1)), + softcore_level=str(soft_options.get("softcore_level") or "lingerie_tease"), + hardcore_level=str(hard_options.get("hardcore_level") or "hardcore"), + platform_style=str(hard_pair.get("platform_style") or soft_pair.get("platform_style") or "hybrid"), + continuity=str(hard_pair.get("continuity") or soft_pair.get("continuity") or "same_creator_same_room"), + hardcore_clothing_continuity=str(hard_options.get("hardcore_clothing_continuity") or "explicit_nude"), + softcore_camera_mode=str(soft_options.get("softcore_camera_mode") or "from_camera_config"), + hardcore_camera_mode=str(hard_options.get("hardcore_camera_mode") or "from_camera_config"), + camera_detail=str(hard_options.get("camera_detail") or soft_options.get("camera_detail") or "from_camera_config"), + softcore_expression_enabled=bool(soft_options.get("softcore_expression_enabled", True)), + hardcore_expression_enabled=bool(hard_options.get("hardcore_expression_enabled", True)), + softcore_expression_intensity=float(soft_options.get("softcore_expression_intensity", 0.45)), + hardcore_expression_intensity=float(hard_options.get("hardcore_expression_intensity", 0.85)), + hardcore_detail_density=str(hard_options.get("hardcore_detail_density") or "balanced"), + ) + + +class SxCPSceneStart: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}), + "start_index": ("INT", {"default": 41, "min": 1, "max": 1000000, "step": 1}), + "seed": ("INT", {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1}), + "target_formatter": (TARGET_FORMATTERS, {"default": "raw"}), + "category_preset": (category_preset_choices(), {"default": "auto_weighted"}), + "subcategory": (subcategory_choices(), {"default": "random"}), + "profile": (generation_profile_choices(), {"default": "balanced"}), + "trigger": ("STRING", {"default": "sxcpinup_coloredpencil"}), + "prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}), + }, + "optional": { + "seed_config": (SXCP_SEED_CONFIG,), + "category_config": (SXCP_CATEGORY_CONFIG,), + "generation_profile": (SXCP_GENERATION_PROFILE,), + "filter_config": (SXCP_FILTER_CONFIG,), + "extra_positive": ("STRING", {"default": "", "multiline": True}), + "extra_negative": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = (SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build( + self, + row_number, + start_index, + seed, + target_formatter, + category_preset, + subcategory, + profile, + trigger, + prepend_trigger_to_prompt, + seed_config="", + category_config="", + generation_profile="", + filter_config="", + extra_positive="", + extra_negative="", + ): + scene = _parse_scene("") + scene.update( + { + "row_number": int(row_number), + "start_index": int(start_index), + "seed": int(seed), + "target_formatter": target_formatter if target_formatter in TARGET_FORMATTERS else "raw", + "category_preset": category_preset, + "subcategory": subcategory, + "profile": profile, + "trigger": str(trigger or ""), + "prepend_trigger_to_prompt": bool(prepend_trigger_to_prompt), + "extra_positive": extra_positive or "", + "extra_negative": extra_negative or "", + } + ) + _set_config(scene, "seed_config", seed_config) + _set_config(scene, "category_config", category_config) + _set_config(scene, "generation_profile", generation_profile) + _set_config(scene, "filter_config", filter_config) + _add_history(scene, "scene_start", f"{category_preset}/{subcategory}; {profile}") + return _scene_out(scene) + + +class SxCPSceneCast: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "cast_mode": (cast_preset_choices(), {"default": "mixed_couple"}), + "women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + "men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + "central_subject": (CENTRAL_SUBJECT_CHOICES, {"default": "auto"}), + "pov_participant": (POV_PARTICIPANT_CHOICES, {"default": "none"}), + }, + "optional": { + "cast_config": (SXCP_CAST_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_CAST_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("scene", "cast_config", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, cast_mode, women_count, men_count, central_subject, pov_participant, cast_config=""): + parsed = _parse_scene(scene) + config = cast_config or build_cast_config_json(cast_mode, women_count, men_count) + _set_config(parsed, "cast_config", config) + layer = { + "cast_mode": cast_mode, + "women_count": int(women_count), + "men_count": int(men_count), + "central_subject": central_subject, + "pov_participant": pov_participant, + } + summary = f"{women_count} women, {men_count} men; central={central_subject}; pov={pov_participant}" + _set_layer(parsed, "cast", layer, summary) + return _dump(parsed), config, summary, _dump(parsed) + + +class SxCPSceneCharacter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "subject_type": (["woman", "man"], {"default": "woman"}), + "label": (character_label_choices(), {"default": "auto_chain"}), + "slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}), + "age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}), + "ethnicity": (character_ethnicity_choices(), {"default": "random"}), + "figure": (character_figure_choices(), {"default": "random"}), + "body": ([choice for choice in character_body_choices() if choice != "manual"], {"default": "random"}), + "descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}), + "expression_enabled": ("BOOLEAN", {"default": True}), + "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "presence_mode": (character_presence_choices(), {"default": "visible"}), + "softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "manual": (SXCP_CHARACTER_MANUAL,), + "ethnicity_list": (SXCP_ETHNICITY_LIST,), + "characteristics": (SXCP_CHARACTERISTICS,), + "hair_config": (SXCP_HAIR_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_CHARACTER_CAST, SXCP_CHARACTER_SLOT, "STRING", "STRING") + RETURN_NAMES = ("scene", "character_cast", "character_slot", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build( + self, + scene, + enabled, + subject_type, + label, + slot_seed, + age, + ethnicity, + figure, + body, + descriptor_detail, + expression_enabled, + expression_intensity, + presence_mode, + softcore_expression_intensity, + hardcore_expression_intensity, + manual="", + ethnicity_list="", + characteristics="", + hair_config="", + ): + parsed = _parse_scene(scene) + result = build_character_slot_json( + subject_type=subject_type, + label=label, + slot_seed=slot_seed, + age=age, + manual=manual, + ethnicity=ethnicity_list or ethnicity, + figure=figure, + body=body, + characteristics=characteristics, + hair_config=hair_config, + descriptor_detail=descriptor_detail, + expression_enabled=expression_enabled, + expression_intensity=expression_intensity, + presence_mode=presence_mode, + softcore_expression_intensity=softcore_expression_intensity, + hardcore_expression_intensity=hardcore_expression_intensity, + enabled=enabled, + character_cast=_base_config(parsed, "character_cast"), + ) + _set_config(parsed, "character_cast", result["character_cast"]) + summary = result["summary"] if enabled else "character disabled" + _add_history(parsed, "character", summary) + return _dump(parsed), result["character_cast"], result["character_slot"], summary, _dump(parsed) + + +class SxCPSceneWardrobe: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "subject_label": (SUBJECT_LABEL_CHOICES, {"default": "all"}), + "clothing_override": (["profile_default", "random", "full", "minimal"], {"default": "profile_default"}), + "softcore_outfit": ("STRING", {"default": "", "multiline": True}), + "hardcore_clothing": ("STRING", {"default": "", "multiline": True}), + "wardrobe_prompt": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_CHARACTER_CAST, "STRING", "STRING") + RETURN_NAMES = ("scene", "character_cast", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, enabled, subject_label, clothing_override, softcore_outfit, hardcore_clothing, wardrobe_prompt): + parsed = _parse_scene(scene) + current_cast = _base_config(parsed, "character_cast") + if enabled: + updated_cast = _update_character_cast_wardrobe(current_cast, subject_label, softcore_outfit, hardcore_clothing) + if updated_cast: + _set_config(parsed, "character_cast", updated_cast) + else: + updated_cast = current_cast + layer = { + "enabled": bool(enabled), + "subject_label": subject_label, + "clothing_override": clothing_override, + "softcore_outfit": softcore_outfit or "", + "hardcore_clothing": hardcore_clothing or "", + "prompt": wardrobe_prompt or "", + } + summary = "disabled" if not enabled else f"{subject_label}; {clothing_override}" + _set_layer(parsed, "wardrobe", layer, summary) + return _dump(parsed), updated_cast or "", summary, _dump(parsed) + + +class SxCPSceneLocation: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "preset": (location_pool_preset_choices(), {"default": "custom_only"}), + "custom_location": ("STRING", {"default": "", "multiline": True}), + "location_note": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "location_config": (SXCP_LOCATION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_LOCATION_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("scene", "location_config", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, enabled, combine_mode, preset, custom_location, location_note, location_config=""): + parsed = _parse_scene(scene) + custom = "\n".join(_text_parts(custom_location, location_note)) + config = build_location_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_locations=custom, + location_config=location_config or _base_config(parsed, "location_config"), + ) + _set_config(parsed, "location_config", config) + config_summary = _json_dict(config).get("summary", "") + _set_layer(parsed, "location", {"preset": preset, "custom_location": custom, "summary": config_summary}, config_summary) + return _dump(parsed), config, config_summary, _dump(parsed) + + +class SxCPSceneSetDressing: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "foreground_anchors": ("STRING", {"default": "", "multiline": True}), + "repeated_background": ("STRING", {"default": "", "multiline": True}), + "props": ("STRING", {"default": "", "multiline": True}), + "set_prompt": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = (SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, enabled, foreground_anchors, repeated_background, props, set_prompt): + parsed = _parse_scene(scene) + prompt = _joined_text(foreground_anchors, repeated_background, props, set_prompt) if enabled else "" + summary = "set dressing disabled" if not enabled else (prompt[:120] or "set dressing empty") + _set_layer( + parsed, + "set_dressing", + { + "enabled": bool(enabled), + "foreground_anchors": foreground_anchors or "", + "repeated_background": repeated_background or "", + "props": props or "", + "prompt": prompt, + }, + summary, + ) + return _scene_out(parsed) + + +class SxCPSceneBlocking: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "blocking_mode": (["auto", "standing", "sitting", "kneeling", "lying", "bent_over", "custom"], {"default": "auto"}), + "subject_placement": ("STRING", {"default": "", "multiline": True}), + "body_relation": ("STRING", {"default": "", "multiline": True}), + "custom_blocking": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = (SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, enabled, blocking_mode, subject_placement, body_relation, custom_blocking): + parsed = _parse_scene(scene) + prompt = _joined_text(subject_placement, body_relation, custom_blocking) if enabled else "" + summary = "blocking disabled" if not enabled else f"{blocking_mode}: {prompt[:100] or 'auto'}" + _set_layer( + parsed, + "blocking", + { + "enabled": bool(enabled), + "blocking_mode": blocking_mode, + "subject_placement": subject_placement or "", + "body_relation": body_relation or "", + "prompt": prompt, + }, + summary, + ) + return _scene_out(parsed) + + +class SxCPSceneAction: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "scene_kind": (SCENE_KINDS, {"default": "regular"}), + "category_preset": (["no_change"] + category_preset_choices(), {"default": "no_change"}), + "action_prompt": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_HARDCORE_POSITION_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("scene", "hardcore_position_config", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, enabled, scene_kind, category_preset, action_prompt, hardcore_position_config=""): + parsed = _parse_scene(scene) + if enabled and category_preset != "no_change": + _set_config(parsed, "category_config", build_category_config_json(category_preset, "random")) + elif enabled and scene_kind == "hardcore" and not _base_config(parsed, "category_config"): + _set_config(parsed, "category_config", build_category_config_json("hardcore_pose", "random")) + if hardcore_position_config: + _set_config(parsed, "hardcore_position_config", hardcore_position_config) + layer = { + "enabled": bool(enabled), + "scene_kind": scene_kind, + "category_preset": category_preset, + "prompt": action_prompt or "", + } + summary = "action disabled" if not enabled else f"{scene_kind}; category={category_preset}" + _set_layer(parsed, "action", layer, summary) + return _dump(parsed), hardcore_position_config or _base_config(parsed, "hardcore_position_config"), summary, _dump(parsed) + + +class SxCPScenePerformance: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "expression_enabled": ("BOOLEAN", {"default": True}), + "expression_intensity_mode": (["profile_default", "random", "fixed"], {"default": "profile_default"}), + "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "performance_prompt": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = (SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, expression_enabled, expression_intensity_mode, expression_intensity, performance_prompt): + parsed = _parse_scene(scene) + layer = { + "expression_enabled": bool(expression_enabled), + "expression_intensity_mode": expression_intensity_mode, + "expression_intensity": float(expression_intensity), + "prompt": performance_prompt or "", + } + summary = "expression disabled" if not expression_enabled else f"expression {expression_intensity_mode}" + _set_layer(parsed, "performance", layer, summary) + return _scene_out(parsed) + + +class SxCPSceneCamera: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "camera_mode": (camera_mode_choices(), {"default": "standard"}), + "shot_size": (camera_shot_choices(), {"default": "auto"}), + "angle": (camera_angle_choices(), {"default": "auto"}), + "lens": (camera_lens_choices(), {"default": "auto"}), + "distance": (camera_distance_choices(), {"default": "auto"}), + "orientation": (camera_orientation_choices(), {"default": "auto"}), + "phone_visibility": (camera_phone_choices(), {"default": "auto"}), + "priority": (camera_priority_choices(), {"default": "strong"}), + "camera_detail": (camera_detail_choices(), {"default": "compact"}), + "camera_prompt": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "camera_config": (SXCP_CAMERA_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_CAMERA_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("scene", "camera_config", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build( + self, + scene, + enabled, + camera_mode, + shot_size, + angle, + lens, + distance, + orientation, + phone_visibility, + priority, + camera_detail, + camera_prompt, + camera_config="", + ): + parsed = _parse_scene(scene) + config = "" + if enabled: + config = camera_config or build_camera_config_json( + camera_mode=camera_mode, + shot_size=shot_size, + angle=angle, + lens=lens, + distance=distance, + orientation=orientation, + phone_visibility=phone_visibility, + priority=priority, + camera_detail=camera_detail, + ) + _set_config(parsed, "camera_config", config) + layer = { + "enabled": bool(enabled), + "camera_mode": camera_mode, + "shot_size": shot_size, + "angle": angle, + "lens": lens, + "distance": distance, + "orientation": orientation, + "phone_visibility": phone_visibility, + "priority": priority, + "camera_detail": camera_detail, + "prompt": camera_prompt or "", + } + summary = "camera disabled" if not enabled else f"{camera_mode}; {shot_size}; {angle}" + _set_layer(parsed, "camera", layer, summary) + return _dump(parsed), config, summary, _dump(parsed) + + +class SxCPSceneComposition: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "combine_mode": (["replace", "add"], {"default": "replace"}), + "preset": (composition_pool_preset_choices(), {"default": "no_outfit_check"}), + "custom_composition": ("STRING", {"default": "", "multiline": True}), + "composition_prompt": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "composition_config": (SXCP_COMPOSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_COMPOSITION_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("scene", "composition_config", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, enabled, combine_mode, preset, custom_composition, composition_prompt, composition_config=""): + parsed = _parse_scene(scene) + custom = "\n".join(_text_parts(custom_composition, composition_prompt)) + config = build_composition_pool_json( + enabled=enabled, + combine_mode=combine_mode, + preset=preset, + custom_compositions=custom, + composition_config=composition_config or _base_config(parsed, "composition_config"), + ) + _set_config(parsed, "composition_config", config) + config_summary = _json_dict(config).get("summary", "") + _set_layer(parsed, "composition", {"preset": preset, "custom_composition": custom, "summary": config_summary}, config_summary) + return _dump(parsed), config, config_summary, _dump(parsed) + + +class SxCPSceneLighting: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "enabled": ("BOOLEAN", {"default": True}), + "lighting_source": (["auto", "daylight", "window_light", "practical_lamps", "neon", "studio", "phone_flash", "custom"], {"default": "auto"}), + "lighting_softness": (["auto", "soft", "balanced", "hard"], {"default": "auto"}), + "lighting_contrast": (["auto", "low", "medium", "high"], {"default": "auto"}), + "color_temperature": (["auto", "warm", "neutral", "cool", "mixed"], {"default": "auto"}), + "custom_lighting": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = (SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build( + self, + scene, + enabled, + lighting_source, + lighting_softness, + lighting_contrast, + color_temperature, + custom_lighting, + ): + parsed = _parse_scene(scene) + generated = " ".join( + value.replace("_", " ") + for value in (lighting_softness, lighting_contrast, color_temperature, lighting_source) + if value and value != "auto" + ).strip() + prompt = _joined_text(generated, custom_lighting) if enabled else "" + summary = "lighting disabled" if not enabled else (prompt or "lighting auto") + _set_layer( + parsed, + "lighting", + { + "enabled": bool(enabled), + "lighting_source": lighting_source, + "lighting_softness": lighting_softness, + "lighting_contrast": lighting_contrast, + "color_temperature": color_temperature, + "prompt": prompt, + }, + summary, + ) + return _scene_out(parsed) + + +class SxCPSceneBranchPair: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "continuity": (["same_creator_same_room", "same_creator_new_scene"], {"default": "same_creator_same_room"}), + "platform_style": (list(INSTA_OF_PLATFORM_STYLES), {"default": "hybrid"}), + } + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("softcore_scene", "hardcore_scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene, continuity, platform_style): + soft_scene = _parse_scene(scene) + hard_scene = _parse_scene(scene) + for branch_name, branch_scene in (("softcore", soft_scene), ("hardcore", hard_scene)): + branch_scene["active_branch"] = branch_name + branch_scene["pair"] = {"continuity": continuity, "platform_style": platform_style} + _branch(branch_scene, branch_name) + _add_history(branch_scene, "branch_pair", f"{branch_name}; {continuity}; {platform_style}") + summary = f"pair branch; {continuity}; {platform_style}" + metadata = {"softcore_scene": soft_scene, "hardcore_scene": hard_scene, "summary": summary} + return _dump(soft_scene), _dump(hard_scene), summary, _dump(metadata) + + +class SxCPSoftcoreBranchOptions: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "softcore_cast": (["solo", "same_as_hardcore"], {"default": "solo"}), + "softcore_level": (list(INSTA_OF_SOFT_LEVELS), {"default": "lingerie_tease"}), + "softcore_expression_enabled": ("BOOLEAN", {"default": True}), + "softcore_expression_intensity": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01}), + "softcore_camera_mode": (SOFTCORE_CAMERA_CHOICES, {"default": "from_camera_config"}), + "camera_detail": (["from_camera_config"] + camera_detail_choices(), {"default": "from_camera_config"}), + "extra_positive": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "softcore_camera_config": (SXCP_CAMERA_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("scene", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build( + self, + scene, + softcore_cast, + softcore_level, + softcore_expression_enabled, + softcore_expression_intensity, + softcore_camera_mode, + camera_detail, + extra_positive, + softcore_camera_config="", + ): + parsed = _parse_scene(scene) + branch = _branch(parsed, "softcore") + branch["options"].update( + { + "softcore_cast": softcore_cast, + "softcore_level": softcore_level, + "softcore_expression_enabled": bool(softcore_expression_enabled), + "softcore_expression_intensity": float(softcore_expression_intensity), + "softcore_camera_mode": softcore_camera_mode, + "camera_detail": camera_detail, + } + ) + branch["extra_positive"] = extra_positive or "" + if softcore_camera_config: + branch["configs"]["camera_config"] = softcore_camera_config + summary = f"softcore {softcore_level}; cast={softcore_cast}; camera={softcore_camera_mode}" + _add_history(parsed, "softcore_branch_options", summary) + return _dump(parsed), summary, _dump(parsed) + + +class SxCPHardcoreBranchOptions: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + "hardcore_cast": (["use_counts", "couple", "threesome", "group"], {"default": "use_counts"}), + "hardcore_women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + "hardcore_men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}), + "hardcore_level": (list(INSTA_OF_HARDCORE_LEVELS), {"default": "hardcore"}), + "hardcore_expression_enabled": ("BOOLEAN", {"default": True}), + "hardcore_expression_intensity": ("FLOAT", {"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.01}), + "hardcore_clothing_continuity": (HARDCORE_CLOTHING_CONTINUITY_CHOICES, {"default": "explicit_nude"}), + "hardcore_camera_mode": (HARDCORE_CAMERA_CHOICES, {"default": "from_camera_config"}), + "camera_detail": (["from_camera_config"] + camera_detail_choices(), {"default": "from_camera_config"}), + "hardcore_detail_density": (hardcore_detail_density_choices(), {"default": "balanced"}), + "extra_positive": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "hardcore_camera_config": (SXCP_CAMERA_CONFIG,), + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_SCENE, SXCP_HARDCORE_POSITION_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("scene", "hardcore_position_config", "summary", "metadata_json") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build( + self, + scene, + hardcore_cast, + hardcore_women_count, + hardcore_men_count, + hardcore_level, + hardcore_expression_enabled, + hardcore_expression_intensity, + hardcore_clothing_continuity, + hardcore_camera_mode, + camera_detail, + hardcore_detail_density, + extra_positive, + hardcore_camera_config="", + hardcore_position_config="", + ): + parsed = _parse_scene(scene) + branch = _branch(parsed, "hardcore") + branch["options"].update( + { + "hardcore_cast": hardcore_cast, + "hardcore_women_count": int(hardcore_women_count), + "hardcore_men_count": int(hardcore_men_count), + "hardcore_level": hardcore_level, + "hardcore_expression_enabled": bool(hardcore_expression_enabled), + "hardcore_expression_intensity": float(hardcore_expression_intensity), + "hardcore_clothing_continuity": hardcore_clothing_continuity, + "hardcore_camera_mode": hardcore_camera_mode, + "camera_detail": camera_detail, + "hardcore_detail_density": hardcore_detail_density, + } + ) + branch["extra_positive"] = extra_positive or "" + if hardcore_camera_config: + branch["configs"]["camera_config"] = hardcore_camera_config + if hardcore_position_config: + branch["configs"]["hardcore_position_config"] = hardcore_position_config + summary = f"hardcore {hardcore_level}; cast={hardcore_cast}; camera={hardcore_camera_mode}" + _add_history(parsed, "hardcore_branch_options", summary) + return _dump(parsed), hardcore_position_config or _base_config(parsed, "hardcore_position_config"), summary, _dump(parsed) + + +class SxCPSceneOutput: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "scene": (SXCP_SCENE,), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", SXCP_SCENE, "STRING", "STRING") + RETURN_NAMES = ("prompt", "negative_prompt", "caption", "metadata_json", "scene", "category", "subcategory") + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, scene): + parsed = _parse_scene(scene) + configs = _compat_configs(parsed, str(parsed.get("active_branch") or "")) + row = build_prompt_from_configs( + row_number=int(parsed.get("row_number", 1)), + start_index=int(parsed.get("start_index", 41)), + seed=int(parsed.get("seed", 20260614)), + category_config=configs["category_config"], + cast_config=configs["cast_config"], + generation_profile=configs["generation_profile"], + filter_config=configs["filter_config"], + seed_config=configs["seed_config"], + camera_config=configs["camera_config"], + character_profile=configs["character_profile"], + character_cast=configs["character_cast"], + hardcore_position_config=configs["hardcore_position_config"], + location_config=configs["location_config"], + composition_config=configs["composition_config"], + extra_positive=configs["extra_positive"], + extra_negative=configs["extra_negative"], + ) + row = dict(row) + row["scene_chain"] = parsed + metadata = _dump(row) + return ( + row["prompt"], + row["negative_prompt"], + row["caption"], + metadata, + _dump(parsed), + row.get("main_category", ""), + row.get("subcategory", ""), + ) + + +class SxCPScenePairOutput: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "softcore_scene": (SXCP_SCENE,), + "hardcore_scene": (SXCP_SCENE,), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", SXCP_SCENE) + RETURN_NAMES = ( + "softcore_prompt", + "hardcore_prompt", + "softcore_negative_prompt", + "hardcore_negative_prompt", + "softcore_caption", + "hardcore_caption", + "shared_descriptor", + "metadata_json", + "scene_metadata_json", + ) + FUNCTION = "build" + CATEGORY = "prompt_builder/v2_scene" + + def build(self, softcore_scene, hardcore_scene): + soft_scene = _parse_scene(softcore_scene) + hard_scene = _parse_scene(hardcore_scene) + base_configs = _compat_configs(soft_scene, "softcore") + hard_configs = _compat_configs(hard_scene, "hardcore") + options_json = _pair_options(soft_scene, hard_scene) + row = build_insta_of_pair( + row_number=int(soft_scene.get("row_number", 1)), + start_index=int(soft_scene.get("start_index", 41)), + seed=int(soft_scene.get("seed", 20260614)), + ethnicity="any", + figure="random", + no_plus_women=False, + no_black=False, + trigger=str(soft_scene.get("trigger") or "sxcpinup_coloredpencil"), + prepend_trigger_to_prompt=bool(soft_scene.get("prepend_trigger_to_prompt", True)), + seed_config=base_configs["seed_config"] or hard_configs["seed_config"], + options_json=options_json, + filter_config=base_configs["filter_config"] or hard_configs["filter_config"], + camera_config=base_configs["camera_config"], + softcore_camera_config=base_configs["camera_config"], + hardcore_camera_config=hard_configs["camera_config"], + character_profile=base_configs["character_profile"] or hard_configs["character_profile"], + character_cast=base_configs["character_cast"] or hard_configs["character_cast"], + hardcore_position_config=hard_configs["hardcore_position_config"], + location_config=base_configs["location_config"] or hard_configs["location_config"], + composition_config=base_configs["composition_config"] or hard_configs["composition_config"], + extra_positive=_joined_text(base_configs["extra_positive"], hard_configs["extra_positive"]), + extra_negative=base_configs["extra_negative"] or hard_configs["extra_negative"], + ) + row = dict(row) + row["scene_chain"] = {"softcore": soft_scene, "hardcore": hard_scene} + metadata = _dump(row) + return ( + row["softcore_prompt"], + row["hardcore_prompt"], + row["softcore_negative_prompt"], + row["hardcore_negative_prompt"], + row["softcore_caption"], + row["hardcore_caption"], + row["shared_descriptor"], + metadata, + _dump({"softcore": soft_scene, "hardcore": hard_scene}), + ) + + +NODE_CLASS_MAPPINGS = { + "SxCPSceneStart": SxCPSceneStart, + "SxCPSceneCast": SxCPSceneCast, + "SxCPSceneCharacter": SxCPSceneCharacter, + "SxCPSceneWardrobe": SxCPSceneWardrobe, + "SxCPSceneLocation": SxCPSceneLocation, + "SxCPSceneSetDressing": SxCPSceneSetDressing, + "SxCPSceneBlocking": SxCPSceneBlocking, + "SxCPSceneAction": SxCPSceneAction, + "SxCPScenePerformance": SxCPScenePerformance, + "SxCPSceneCamera": SxCPSceneCamera, + "SxCPSceneComposition": SxCPSceneComposition, + "SxCPSceneLighting": SxCPSceneLighting, + "SxCPSceneBranchPair": SxCPSceneBranchPair, + "SxCPSoftcoreBranchOptions": SxCPSoftcoreBranchOptions, + "SxCPHardcoreBranchOptions": SxCPHardcoreBranchOptions, + "SxCPSceneOutput": SxCPSceneOutput, + "SxCPScenePairOutput": SxCPScenePairOutput, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SxCPSceneStart": "SxCP Scene Start", + "SxCPSceneCast": "SxCP Scene Cast", + "SxCPSceneCharacter": "SxCP Scene Character", + "SxCPSceneWardrobe": "SxCP Scene Wardrobe", + "SxCPSceneLocation": "SxCP Scene Location", + "SxCPSceneSetDressing": "SxCP Scene Set Dressing", + "SxCPSceneBlocking": "SxCP Scene Blocking", + "SxCPSceneAction": "SxCP Scene Action", + "SxCPScenePerformance": "SxCP Scene Performance", + "SxCPSceneCamera": "SxCP Scene Camera", + "SxCPSceneComposition": "SxCP Scene Composition", + "SxCPSceneLighting": "SxCP Scene Lighting", + "SxCPSceneBranchPair": "SxCP Scene Branch Pair", + "SxCPSoftcoreBranchOptions": "SxCP Softcore Branch Options", + "SxCPHardcoreBranchOptions": "SxCP Hardcore Branch Options", + "SxCPSceneOutput": "SxCP Scene Output", + "SxCPScenePairOutput": "SxCP Scene Pair Output", +} diff --git a/node_tooltips.py b/node_tooltips.py index 57a294c..8dafbac 100644 --- a/node_tooltips.py +++ b/node_tooltips.py @@ -30,6 +30,46 @@ COMMON_INPUT_TOOLTIPS = { "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.", + "scene": "Structured v2 scene context. Chain Scene nodes in order, then connect to Scene Output or Scene Pair Output.", + "softcore_scene": "Softcore branch scene from Scene Branch Pair, optionally refined by Softcore Branch Options.", + "hardcore_scene": "Hardcore branch scene from Scene Branch Pair, optionally refined by Hardcore Branch Options.", + "target_formatter": "Intended downstream formatter target. The scene stores this as metadata; use formatter nodes for final rewriting.", + "category_preset": "Category preset this scene should render through when no explicit category config overrides it.", + "central_subject": "Who should be visually central in this scene metadata.", + "pov_participant": "Optional participant treated as the first-person viewer in later character/camera logic.", + "subject_label": "Character label affected by this layer. all applies the layer to every matching character slot.", + "wardrobe_prompt": "Optional wardrobe/set note carried as scene metadata and compatibility extra prompt text.", + "custom_location": "Exact location text for this scene. One line or JSON entry is enough.", + "location_note": "Additional location wording merged into the location pool entry.", + "foreground_anchors": "Objects or surfaces that should stay near the camera or lower frame.", + "repeated_background": "Repeating background structure such as desks, doors, shelves, pillars, or windows.", + "props": "Scene props or set dressing objects that make the location readable.", + "set_prompt": "Freeform set-dressing sentence appended to the scene layer.", + "blocking_mode": "Broad body-placement mode. custom lets custom_blocking carry the exact placement.", + "subject_placement": "Where the subject or cast sits in the space: foreground, near desk edge, on bed, in aisle, etc.", + "body_relation": "Spatial relationship between participants, separate from the action itself.", + "custom_blocking": "Exact blocking/positioning sentence for the scene layer.", + "scene_kind": "Regular, softcore, or hardcore intent for this action layer.", + "action_prompt": "Action text stored separately from blocking and camera. Use position pools for hardcore randomization when possible.", + "performance_prompt": "Expression, gaze, hand, and body-performance note stored separately from the action.", + "camera_prompt": "Optional freeform camera note kept as scene metadata. Camera config still controls existing formatter behavior.", + "custom_composition": "Exact composition/framing entry to add to the composition pool.", + "composition_prompt": "Additional composition wording merged into the composition layer.", + "lighting_source": "Main light source family for the scene.", + "lighting_softness": "Softness of the light: soft, balanced, or hard.", + "lighting_contrast": "Overall contrast level for the lighting layer.", + "color_temperature": "Warm, neutral, cool, or mixed color temperature.", + "custom_lighting": "Exact lighting sentence for the scene layer.", + "continuity": "How branch outputs share cast/location setup between softcore and hardcore scenes.", + "platform_style": "Instagram/OnlyFans styling bias for Scene Pair Output.", + "softcore_cast": "Whether the softcore branch uses a solo creator or the same cast as the hardcore branch.", + "hardcore_cast": "Hardcore branch cast preset or explicit count mode.", + "softcore_level": "Softcore exposure/style level for Scene Pair Output.", + "hardcore_level": "Hardcore intensity level for Scene Pair Output.", + "softcore_camera_mode": "Softcore branch camera mode, or from_camera_config to use the connected scene camera.", + "hardcore_camera_mode": "Hardcore branch camera mode, or from_camera_config to use the connected scene camera.", + "hardcore_clothing_continuity": "How wardrobe is rendered in the hardcore branch. explicit_nude avoids clothing-token conflicts.", + "hardcore_detail_density": "How much explicit action detail the current formatter route keeps for the hardcore branch.", "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.", "input_hint": "Tells the node how to interpret source_text. auto tries metadata first.", diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 0a33727..7677a73 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -8790,6 +8790,174 @@ def smoke_node_insta_registration() -> None: _expect(pair.get("options", {}).get("hardcore_cast") == "couple", "Insta/OF Prompt Pair lost options metadata") +def smoke_node_scene_chain_registration() -> None: + required_nodes = [ + "SxCPSceneStart", + "SxCPSceneCast", + "SxCPSceneCharacter", + "SxCPSceneWardrobe", + "SxCPSceneLocation", + "SxCPSceneSetDressing", + "SxCPSceneBlocking", + "SxCPSceneAction", + "SxCPScenePerformance", + "SxCPSceneCamera", + "SxCPSceneComposition", + "SxCPSceneLighting", + "SxCPSceneBranchPair", + "SxCPSoftcoreBranchOptions", + "SxCPHardcoreBranchOptions", + "SxCPSceneOutput", + "SxCPScenePairOutput", + ] + for node_name in required_nodes: + _expect(node_name in sxcp_nodes.NODE_CLASS_MAPPINGS, f"{node_name} missing from node registry") + _expect(node_name in sxcp_nodes.NODE_DISPLAY_NAME_MAPPINGS, f"{node_name} missing from display registry") + + nodes = sxcp_nodes.NODE_CLASS_MAPPINGS + scene, start_summary, _start_metadata = nodes["SxCPSceneStart"]().build( + 1, + 41, + 777, + "raw", + "woman", + "random", + "balanced", + Trigger, + True, + ) + _expect("scene v" in start_summary, "Scene Start summary changed unexpectedly") + parsed_scene = json.loads(scene) + _expect(parsed_scene.get("schema") == "sxcp_scene_v2", "Scene Start did not emit v2 schema") + + scene, _cast_config, _cast_summary, _cast_metadata = nodes["SxCPSceneCast"]().build( + scene, + "mixed_couple", + 1, + 1, + "woman_a", + "none", + ) + scene, character_cast, _slot, _summary, _metadata = nodes["SxCPSceneCharacter"]().build( + scene, + True, + "woman", + "A", + -1, + "25-year-old adult", + "random", + "random", + "random", + "medium", + True, + 0.5, + "visible", + -1, + -1, + ) + scene, character_cast, _slot, _summary, _metadata = nodes["SxCPSceneCharacter"]().build( + scene, + True, + "man", + "A", + -1, + "40-year-old adult", + "random", + "random", + "average", + "compact", + True, + 0.5, + "visible", + -1, + -1, + ) + scene, character_cast, _wardrobe_summary, _wardrobe_metadata = nodes["SxCPSceneWardrobe"]().build( + scene, + True, + "A", + "full", + "simple black dress", + "fully nude", + "", + ) + slots = json.loads(character_cast).get("slots") or [] + woman_slot = next(slot for slot in slots if slot.get("subject_type") == "woman") + _expect(woman_slot.get("softcore_outfit") == "simple black dress", "Scene Wardrobe did not update softcore outfit") + _expect(woman_slot.get("hardcore_clothing") == "fully nude", "Scene Wardrobe did not update hardcore clothing") + + scene = nodes["SxCPSceneLocation"]().build( + scene, + True, + "replace", + "custom_only", + "quiet studio room with a large mirror", + "", + )[0] + scene = nodes["SxCPSceneSetDressing"]().build(scene, True, "mirror edge", "soft curtains", "small lamp", "")[0] + scene = nodes["SxCPSceneBlocking"]().build(scene, True, "standing", "woman near mirror", "man behind her", "")[0] + scene = nodes["SxCPScenePerformance"]().build(scene, True, "fixed", 0.4, "controlled eye contact")[0] + scene = nodes["SxCPSceneCamera"]().build( + scene, + True, + "standard", + "three_quarter", + "eye_level", + "auto", + "auto", + "auto", + "auto", + "strong", + "compact", + "", + )[0] + scene = nodes["SxCPSceneComposition"]().build(scene, True, "replace", "no_outfit_check", "mirror-aware three-quarter frame", "")[0] + scene = nodes["SxCPSceneLighting"]().build(scene, True, "practical_lamps", "soft", "medium", "warm", "")[0] + + output = nodes["SxCPSceneOutput"]().build(scene) + _expect_text("node_scene_chain.prompt", output[0], 40) + _expect_trigger_once("node_scene_chain.prompt", output[0], Trigger) + row = json.loads(output[3]) + _expect(row.get("scene_chain", {}).get("schema") == "sxcp_scene_v2", "Scene Output lost scene_chain metadata") + + soft_scene, hard_scene, _branch_summary, _branch_metadata = nodes["SxCPSceneBranchPair"]().build( + scene, + "same_creator_same_room", + "hybrid", + ) + soft_scene = nodes["SxCPSoftcoreBranchOptions"]().build( + soft_scene, + "same_as_hardcore", + "lingerie_tease", + True, + 0.45, + "from_camera_config", + "compact", + "", + )[0] + hard_scene = nodes["SxCPHardcoreBranchOptions"]().build( + hard_scene, + "couple", + 1, + 1, + "hardcore", + True, + 0.85, + "explicit_nude", + "from_camera_config", + "compact", + "balanced", + "", + )[0] + pair_output = nodes["SxCPScenePairOutput"]().build(soft_scene, hard_scene) + _expect_text("node_scene_chain.softcore_prompt", pair_output[0], 40) + _expect_text("node_scene_chain.hardcore_prompt", pair_output[1], 40) + pair = json.loads(pair_output[7]) + _expect_pair(pair, "node_scene_chain_pair") + _expect(pair.get("options", {}).get("hardcore_cast") == "couple", "Scene Pair Output lost hardcore branch options") + _expect("scene_chain" in pair, "Scene Pair Output lost scene_chain metadata") + + def smoke_node_builder_registration() -> None: required_nodes = [ "SxCPPromptBuilder", @@ -9018,6 +9186,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("node_hardcore_position_registration", smoke_node_hardcore_position_registration), ("node_formatter_registration", smoke_node_formatter_registration), ("node_insta_registration", smoke_node_insta_registration), + ("node_scene_chain_registration", smoke_node_scene_chain_registration), ("node_builder_registration", smoke_node_builder_registration), ("node_profile_filter_registration", smoke_node_profile_filter_registration), ]