diff --git a/__init__.py b/__init__.py index 58ee12c..9d3bf30 100644 --- a/__init__.py +++ b/__init__.py @@ -408,6 +408,10 @@ try: NODE_CLASS_MAPPINGS as CHARACTER_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CHARACTER_NODE_DISPLAY_NAME_MAPPINGS, ) + from .node_hardcore_position import ( + NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS, + ) from .node_profile_filter import ( NODE_CLASS_MAPPINGS as PROFILE_FILTER_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as PROFILE_FILTER_NODE_DISPLAY_NAME_MAPPINGS, @@ -421,8 +425,6 @@ try: NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, ) from .prompt_builder import ( - build_hardcore_action_filter_json, - build_hardcore_position_pool_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, @@ -431,9 +433,6 @@ try: camera_mode_choices, category_choices, ethnicity_choices, - hardcore_position_family_choices, - hardcore_position_focus_choices, - hardcore_position_key_choices, hardcore_detail_density_choices, save_character_profile_payload, subcategory_choices, @@ -458,6 +457,10 @@ except ImportError: NODE_CLASS_MAPPINGS as CHARACTER_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CHARACTER_NODE_DISPLAY_NAME_MAPPINGS, ) + from node_hardcore_position import ( + NODE_CLASS_MAPPINGS as HARDCORE_POSITION_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as HARDCORE_POSITION_NODE_DISPLAY_NAME_MAPPINGS, + ) from node_profile_filter import ( NODE_CLASS_MAPPINGS as PROFILE_FILTER_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as PROFILE_FILTER_NODE_DISPLAY_NAME_MAPPINGS, @@ -471,8 +474,6 @@ except ImportError: NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, ) from prompt_builder import ( - build_hardcore_action_filter_json, - build_hardcore_position_pool_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, @@ -481,9 +482,6 @@ except ImportError: camera_mode_choices, category_choices, ethnicity_choices, - hardcore_position_family_choices, - hardcore_position_focus_choices, - hardcore_position_key_choices, hardcore_detail_density_choices, save_character_profile_payload, subcategory_choices, @@ -684,108 +682,6 @@ class SxCPPromptBuilder: ) -def _choice_input_key(prefix, choice): - key = "".join(char if char.isalnum() else "_" for char in str(choice).lower()).strip("_") - while "__" in key: - key = key.replace("__", "_") - return f"{prefix}_{key}" - - -class SxCPHardcorePositionPool: - @classmethod - def INPUT_TYPES(cls): - required = { - "combine_mode": (["replace", "add"], {"default": "replace"}), - "family": (hardcore_position_family_choices(), {"default": "any"}), - } - for choice in hardcore_position_key_choices(): - required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False}) - return { - "required": required, - "optional": { - "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), - }, - } - - RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") - RETURN_NAMES = ("hardcore_position_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build(self, combine_mode="replace", family="any", hardcore_position_config="", **kwargs): - selected = [ - choice - for choice in hardcore_position_key_choices() - if bool(kwargs.get(_choice_input_key("include", choice), False)) - ] - config = build_hardcore_position_pool_json( - hardcore_position_config=hardcore_position_config or "", - combine_mode=combine_mode, - family=family, - selected_positions=selected, - ) - return config, json.loads(config).get("summary", "") - - -class SxCPHardcoreActionFilter: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "focus": (hardcore_position_focus_choices(), {"default": "keep_pool"}), - "allow_toys": ("BOOLEAN", {"default": False}), - "allow_double": ("BOOLEAN", {"default": False}), - "allow_penetration": ("BOOLEAN", {"default": True}), - "allow_foreplay": ("BOOLEAN", {"default": True}), - "allow_interaction": ("BOOLEAN", {"default": True}), - "allow_manual": ("BOOLEAN", {"default": True}), - "allow_oral": ("BOOLEAN", {"default": True}), - "allow_outercourse": ("BOOLEAN", {"default": True}), - "allow_anal": ("BOOLEAN", {"default": True}), - "allow_climax": ("BOOLEAN", {"default": True}), - }, - "optional": { - "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), - }, - } - - RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") - RETURN_NAMES = ("hardcore_position_config", "summary") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build( - self, - focus, - allow_toys, - allow_double, - allow_penetration, - allow_foreplay, - allow_interaction, - allow_manual, - allow_oral, - allow_outercourse, - allow_anal, - allow_climax, - hardcore_position_config="", - ): - config = build_hardcore_action_filter_json( - hardcore_position_config=hardcore_position_config or "", - focus=focus, - allow_toys=allow_toys, - allow_double=allow_double, - allow_penetration=allow_penetration, - allow_foreplay=allow_foreplay, - allow_interaction=allow_interaction, - allow_manual=allow_manual, - allow_oral=allow_oral, - allow_outercourse=allow_outercourse, - allow_anal=allow_anal, - allow_climax=allow_climax, - ) - return config, json.loads(config).get("summary", "") - - class SxCPPromptBuilderFromConfigs: @classmethod def INPUT_TYPES(cls): @@ -1254,11 +1150,10 @@ NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS.update(SEED_RESOLUTION_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(CAMERA_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(CHARACTER_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(HARDCORE_POSITION_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({ - "SxCPHardcorePositionPool": SxCPHardcorePositionPool, - "SxCPHardcoreActionFilter": SxCPHardcoreActionFilter, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, "SxCPCaptionNaturalizer": SxCPCaptionNaturalizer, "SxCPKrea2Formatter": SxCPKrea2Formatter, @@ -1275,11 +1170,10 @@ NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS.update(SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(CAMERA_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(CHARACTER_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(HARDCORE_POSITION_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({ - "SxCPHardcorePositionPool": "SxCP Hardcore Position Pool", - "SxCPHardcoreActionFilter": "SxCP Hardcore Action Filter", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", "SxCPCaptionNaturalizer": "SxCP Caption Naturalizer", "SxCPKrea2Formatter": "SxCP Krea2 Formatter", diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index e14e5b4..aea286a 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -273,8 +273,8 @@ Improve later: ### Node / UI Path Owner: `__init__.py`, `node_seed_resolution.py`, `node_camera.py`, -`node_character.py`, `node_route_config.py`, `node_profile_filter.py`, -`loop_nodes.py`, `web/*.js`. +`node_character.py`, `node_hardcore_position.py`, `node_route_config.py`, +`node_profile_filter.py`, `loop_nodes.py`, `web/*.js`. Keep here: @@ -285,6 +285,8 @@ Keep here: - seed and resolution utility node declarations in `node_seed_resolution.py`. - camera utility node declarations in `node_camera.py`. - character pool, slot, and profile node declarations in `node_character.py`. +- hardcore position pool/filter node declarations in + `node_hardcore_position.py`. - route/category/location/composition/cast config node declarations in `node_route_config.py`. - profile/filter/ethnicity-list node declarations in `node_profile_filter.py`. @@ -298,6 +300,9 @@ Already isolated: - hair, age/body/eyes/clothing pools, manual character details, character slots, and profile save/load nodes live in `node_character.py`, with registration maps imported by `__init__.py`. +- hardcore position pool and action filter nodes live in + `node_hardcore_position.py`, with registration maps imported by + `__init__.py`. - category preset, location/composition pool, location theme, and cast config utility nodes live in `node_route_config.py`, with registration maps imported by `__init__.py`. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 010de59..7d3eb95 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -26,8 +26,8 @@ When a result is wrong, first identify which layer owns the bad text: - Natural caption/training caption wrong: edit `caption_naturalizer.py`. - UI/preview/loop behavior wrong: edit `__init__.py`, node family modules such as `node_seed_resolution.py`, `node_camera.py`, `node_character.py`, - `node_route_config.py`, or `node_profile_filter.py`, `loop_nodes.py`, or - `web/*.js`. + `node_hardcore_position.py`, `node_route_config.py`, or + `node_profile_filter.py`, `loop_nodes.py`, or `web/*.js`. ## High-Level Routes @@ -696,6 +696,7 @@ These do not own prompt pool wording, but they affect execution and review: | Seed and resolution utility nodes | `node_seed_resolution.py`, imported by `__init__.py` | Global/per-axis seed configs plus SDXL/Krea width/height helpers. | | Camera utility nodes | `node_camera.py`, imported by `__init__.py` | Direct camera config, orbit-to-camera config, and Qwen MultiAngle camera translation. | | Character utility nodes | `node_character.py`, imported by `__init__.py` | Hair, age/body/eyes/clothing pools, manual details, character slots, and profile save/load nodes. | +| Hardcore position utility nodes | `node_hardcore_position.py`, imported by `__init__.py` | Position-family pool and action/filter gates for hardcore routes. | | Route config utility nodes | `node_route_config.py`, imported by `__init__.py` | Category preset, location/composition pool, location theme, and cast config helpers. | | Profile/filter utility nodes | `node_profile_filter.py`, imported by `__init__.py` | Generation profile, advanced filter config, and ethnicity list helpers. | diff --git a/node_hardcore_position.py b/node_hardcore_position.py new file mode 100644 index 0000000..7b27193 --- /dev/null +++ b/node_hardcore_position.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json + +try: + from .prompt_builder import ( + build_hardcore_action_filter_json, + build_hardcore_position_pool_json, + hardcore_position_family_choices, + hardcore_position_focus_choices, + hardcore_position_key_choices, + ) +except ImportError: # Allows local smoke tests from the repository root. + from prompt_builder import ( + build_hardcore_action_filter_json, + build_hardcore_position_pool_json, + hardcore_position_family_choices, + hardcore_position_focus_choices, + hardcore_position_key_choices, + ) + + +SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG" + + +def _choice_input_key(prefix, choice): + key = "".join(char if char.isalnum() else "_" for char in str(choice).lower()).strip("_") + while "__" in key: + key = key.replace("__", "_") + return f"{prefix}_{key}" + + +class SxCPHardcorePositionPool: + @classmethod + def INPUT_TYPES(cls): + required = { + "combine_mode": (["replace", "add"], {"default": "replace"}), + "family": (hardcore_position_family_choices(), {"default": "any"}), + } + for choice in hardcore_position_key_choices(): + required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False}) + return { + "required": required, + "optional": { + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") + RETURN_NAMES = ("hardcore_position_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, combine_mode="replace", family="any", hardcore_position_config="", **kwargs): + selected = [ + choice + for choice in hardcore_position_key_choices() + if bool(kwargs.get(_choice_input_key("include", choice), False)) + ] + config = build_hardcore_position_pool_json( + hardcore_position_config=hardcore_position_config or "", + combine_mode=combine_mode, + family=family, + selected_positions=selected, + ) + return config, json.loads(config).get("summary", "") + + +class SxCPHardcoreActionFilter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "focus": (hardcore_position_focus_choices(), {"default": "keep_pool"}), + "allow_toys": ("BOOLEAN", {"default": False}), + "allow_double": ("BOOLEAN", {"default": False}), + "allow_penetration": ("BOOLEAN", {"default": True}), + "allow_foreplay": ("BOOLEAN", {"default": True}), + "allow_interaction": ("BOOLEAN", {"default": True}), + "allow_manual": ("BOOLEAN", {"default": True}), + "allow_oral": ("BOOLEAN", {"default": True}), + "allow_outercourse": ("BOOLEAN", {"default": True}), + "allow_anal": ("BOOLEAN", {"default": True}), + "allow_climax": ("BOOLEAN", {"default": True}), + }, + "optional": { + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + }, + } + + RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") + RETURN_NAMES = ("hardcore_position_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + focus, + allow_toys, + allow_double, + allow_penetration, + allow_foreplay, + allow_interaction, + allow_manual, + allow_oral, + allow_outercourse, + allow_anal, + allow_climax, + hardcore_position_config="", + ): + config = build_hardcore_action_filter_json( + hardcore_position_config=hardcore_position_config or "", + focus=focus, + allow_toys=allow_toys, + allow_double=allow_double, + allow_penetration=allow_penetration, + allow_foreplay=allow_foreplay, + allow_interaction=allow_interaction, + allow_manual=allow_manual, + allow_oral=allow_oral, + allow_outercourse=allow_outercourse, + allow_anal=allow_anal, + allow_climax=allow_climax, + ) + return config, json.loads(config).get("summary", "") + + +NODE_CLASS_MAPPINGS = { + "SxCPHardcorePositionPool": SxCPHardcorePositionPool, + "SxCPHardcoreActionFilter": SxCPHardcoreActionFilter, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SxCPHardcorePositionPool": "SxCP Hardcore Position Pool", + "SxCPHardcoreActionFilter": "SxCP Hardcore Action Filter", +} diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index d1a6add..68668b9 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -2012,6 +2012,53 @@ def smoke_node_character_registration() -> None: _expect(json.loads(loaded_profile[0]).get("profile_type") == "character", "Profile Load returned wrong profile type") +def smoke_node_hardcore_position_registration() -> None: + required_nodes = [ + "SxCPHardcorePositionPool", + "SxCPHardcoreActionFilter", + ] + 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") + + position_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPHardcorePositionPool"] + position_inputs = position_node.INPUT_TYPES().get("required") or {} + _expect("family" in position_inputs, "Hardcore Position Pool lost family input") + _expect("tooltip" in position_inputs["family"][1], "Hardcore Position Pool tooltip injection missing") + pool_config, pool_summary = position_node().build( + "replace", + "oral", + "", + include_boobjob=True, + include_handjob=True, + ) + parsed_pool = json.loads(pool_config) + _expect(parsed_pool.get("family") == "oral", "Hardcore Position Pool lost selected family") + _expect(parsed_pool.get("positions") == ["boobjob", "handjob"], "Hardcore Position Pool lost selected positions") + _expect("positions=boobjob,handjob" in pool_summary, "Hardcore Position Pool summary changed unexpectedly") + + filter_config, filter_summary = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPHardcoreActionFilter"]().build( + "outercourse_only", + False, + False, + False, + True, + True, + True, + False, + True, + False, + False, + pool_config, + ) + parsed_filter = json.loads(filter_config) + _expect(parsed_filter.get("family") == "outercourse", "Hardcore Action Filter did not apply focus family") + _expect(parsed_filter.get("positions") == ["boobjob", "handjob"], "Hardcore Action Filter lost incoming positions") + _expect(parsed_filter.get("allow_penetration") is False, "Hardcore Action Filter did not block penetration") + _expect(parsed_filter.get("allow_outercourse") is True, "Hardcore Action Filter should allow outercourse") + _expect("blocked=" in filter_summary, "Hardcore Action Filter summary lost blocked-gate details") + + def smoke_node_profile_filter_registration() -> None: required_nodes = [ "SxCPGenerationProfile", @@ -2120,6 +2167,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("node_camera_registration", smoke_node_camera_registration), ("node_route_config_registration", smoke_node_route_config_registration), ("node_character_registration", smoke_node_character_registration), + ("node_hardcore_position_registration", smoke_node_hardcore_position_registration), ("node_profile_filter_registration", smoke_node_profile_filter_registration), ]