diff --git a/hardcore_position_config.py b/hardcore_position_config.py index 30ac544..f4aaee5 100644 --- a/hardcore_position_config.py +++ b/hardcore_position_config.py @@ -217,6 +217,20 @@ HARDCORE_POSITION_AXIS_KEYS = { "aftercare_act", "cleanup_detail", } +RESTORE_PROMPT_AXIS_CHOICES = [ + "clothing_detail", + "face_detail", + "expression_detail", + "mouth_detail", + "reaction_detail", + "body_contact", + "hand_detail", + "touch_detail", + "foreplay_detail", + "performance_act", + "visibility", + "angle", +] HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY = { "penetrative_sex": "penetrative", "foreplay_teasing": "foreplay", @@ -264,6 +278,16 @@ def _list_from(value: Any) -> list[Any]: return [value] +def normalize_restore_prompt_axes(values: Any) -> list[str]: + allowed = set(RESTORE_PROMPT_AXIS_CHOICES) + normalized: list[str] = [] + for value in _list_from(values): + text = str(value or "").strip() + if text in allowed and text not in normalized: + normalized.append(text) + return normalized + + def _entry_text(item: Any) -> str: if isinstance(item, dict): return str( @@ -376,6 +400,8 @@ def empty_hardcore_position_config() -> dict[str, Any]: "allow_outercourse": True, "allow_anal": True, "allow_climax": True, + "restore_prompt_axes": [], + "relax_non_pose_axis_conflicts": False, } @@ -409,6 +435,8 @@ def parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[s "allow_climax", ): parsed[key] = not _is_false(parsed.get(key, True)) + parsed["restore_prompt_axes"] = normalize_restore_prompt_axes(parsed.get("restore_prompt_axes")) + parsed["relax_non_pose_axis_conflicts"] = not _is_false(parsed.get("relax_non_pose_axis_conflicts", False)) return parsed @@ -439,6 +467,11 @@ def hardcore_position_summary(config: dict[str, Any]) -> str: ] if disabled: parts.append("blocked=" + ",".join(disabled)) + restore_axes = normalize_restore_prompt_axes(config.get("restore_prompt_axes")) + if restore_axes: + parts.append("restore_axes=" + ",".join(restore_axes)) + if restore_axes and config.get("relax_non_pose_axis_conflicts"): + parts.append("relaxed_non_pose_conflicts") return "; ".join(parts) @@ -787,6 +820,14 @@ def hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> boo return bool(matched) and not bool(matched & selected) +def restored_prompt_axis_relaxes_conflicts(axis_name: str, config: dict[str, Any]) -> bool: + if str(axis_name or "") in HARDCORE_POSITION_AXIS_KEYS: + return False + if not config.get("relax_non_pose_axis_conflicts"): + return False + return str(axis_name or "") in set(normalize_restore_prompt_axes(config.get("restore_prompt_axes"))) + + def hardcore_subcategory_supports_positions(subcategory: dict[str, Any], config: dict[str, Any]) -> bool: if not hardcore_position_template_required(config): return True @@ -809,7 +850,11 @@ def filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, An value for value in values if not hardcore_entry_blocked_by_action(value, axis_name, config) - and not (axis_name not in HARDCORE_POSITION_AXIS_KEYS and hardcore_position_entry_conflicts(value, config)) + and not ( + axis_name not in HARDCORE_POSITION_AXIS_KEYS + and not restored_prompt_axis_relaxes_conflicts(axis_name, config) + and hardcore_position_entry_conflicts(value, config) + ) and (axis_name not in HARDCORE_POSITION_AXIS_KEYS or hardcore_position_entry_matches(value, config)) ] return filtered or values diff --git a/node_hardcore_position.py b/node_hardcore_position.py index 15fcb28..c95f306 100644 --- a/node_hardcore_position.py +++ b/node_hardcore_position.py @@ -13,6 +13,7 @@ try: hardcore_position_focus_choices, hardcore_position_key_choices, hardcore_position_summary, + normalize_restore_prompt_axes, parse_hardcore_position_config, ) except ImportError: # Allows local smoke tests from the repository root. @@ -26,6 +27,7 @@ except ImportError: # Allows local smoke tests from the repository root. hardcore_position_focus_choices, hardcore_position_key_choices, hardcore_position_summary, + normalize_restore_prompt_axes, parse_hardcore_position_config, ) @@ -339,6 +341,62 @@ class SxCPKrea2POVInteractionFilter(_SxCPKrea2POVVariantFilter): POSITION_FAMILY = "interaction" +class SxCPKrea2POVPromptRestore: + CLOTHING_AXES = ["clothing_detail"] + FACE_EXPRESSION_AXES = ["face_detail", "expression_detail", "mouth_detail", "reaction_detail"] + BODY_TOUCH_AXES = ["body_contact", "hand_detail", "touch_detail", "foreplay_detail"] + CAMERA_PRESENTATION_AXES = ["performance_act", "visibility", "angle"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,), + "restore_clothing_detail": ("BOOLEAN", {"default": True}), + "restore_face_expression_detail": ("BOOLEAN", {"default": True}), + "restore_body_touch_detail": ("BOOLEAN", {"default": False}), + "restore_camera_presentation_detail": ("BOOLEAN", {"default": False}), + "relax_non_pose_axis_conflicts": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING") + RETURN_NAMES = ("hardcore_position_config", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + hardcore_position_config, + restore_clothing_detail=True, + restore_face_expression_detail=True, + restore_body_touch_detail=False, + restore_camera_presentation_detail=False, + relax_non_pose_axis_conflicts=True, + ): + config = parse_hardcore_position_config(hardcore_position_config) + axes: list[str] = [] + if restore_clothing_detail: + axes.extend(self.CLOTHING_AXES) + config["allow_foreplay"] = True + config["allow_interaction"] = True + if restore_face_expression_detail: + axes.extend(self.FACE_EXPRESSION_AXES) + config["allow_foreplay"] = True + config["allow_interaction"] = True + if restore_body_touch_detail: + axes.extend(self.BODY_TOUCH_AXES) + config["allow_foreplay"] = True + config["allow_interaction"] = True + if restore_camera_presentation_detail: + axes.extend(self.CAMERA_PRESENTATION_AXES) + config["allow_interaction"] = True + config["restore_prompt_axes"] = normalize_restore_prompt_axes(axes) + config["relax_non_pose_axis_conflicts"] = bool(relax_non_pose_axis_conflicts) + config["summary"] = hardcore_position_summary(config) + return json.dumps(config, ensure_ascii=True, sort_keys=True), str(config["summary"]) + + class SxCPKrea2VariantEvidence: @classmethod def INPUT_TYPES(cls): @@ -462,6 +520,7 @@ NODE_CLASS_MAPPINGS = { "SxCPKrea2POVToyFilter": SxCPKrea2POVToyFilter, "SxCPKrea2POVClimaxFilter": SxCPKrea2POVClimaxFilter, "SxCPKrea2POVInteractionFilter": SxCPKrea2POVInteractionFilter, + "SxCPKrea2POVPromptRestore": SxCPKrea2POVPromptRestore, "SxCPKrea2VariantEvidence": SxCPKrea2VariantEvidence, } @@ -476,5 +535,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPKrea2POVToyFilter": "SxCP Krea2 POV Toy Filter", "SxCPKrea2POVClimaxFilter": "SxCP Krea2 POV Climax Filter", "SxCPKrea2POVInteractionFilter": "SxCP Krea2 POV Interaction Filter", + "SxCPKrea2POVPromptRestore": "SxCP Krea2 POV Prompt Restore", "SxCPKrea2VariantEvidence": "SxCP Krea2 Variant Evidence", } diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 33b1450..2a01cd9 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -11195,6 +11195,7 @@ def smoke_node_hardcore_position_registration() -> None: "SxCPKrea2POVToyFilter", "SxCPKrea2POVClimaxFilter", "SxCPKrea2POVInteractionFilter", + "SxCPKrea2POVPromptRestore", "SxCPKrea2VariantEvidence", ] for node_name in required_nodes: @@ -11350,6 +11351,80 @@ def smoke_node_hardcore_position_registration() -> None: _expect("doggy" in mixed_positions and "penis_licking" in mixed_positions, "Chained POV filters returned wrong positions summary") _expect("family=any" in mixed_summary, "Chained POV filters summary should show mixed family") + restore_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPKrea2POVPromptRestore"] + restore_inputs = restore_node.INPUT_TYPES().get("required") or {} + for key in ( + "restore_clothing_detail", + "restore_face_expression_detail", + "restore_body_touch_detail", + "restore_camera_presentation_detail", + "relax_non_pose_axis_conflicts", + ): + _expect(key in restore_inputs, f"Krea2 POV Prompt Restore lost input {key}") + restored_config, restored_summary = restore_node().build( + doggy_config, + True, + True, + False, + False, + True, + ) + parsed_restored_config = json.loads(restored_config) + _expect( + parsed_restored_config.get("krea2_variant_keys") == ["pov_doggy_top_down_rear_entry"], + "Krea2 POV Prompt Restore should preserve selected atlas variant metadata", + ) + _expect(parsed_restored_config.get("positions") == ["doggy"], "Krea2 POV Prompt Restore should preserve selected pose lock") + _expect(parsed_restored_config.get("family") == "penetrative", "Krea2 POV Prompt Restore should preserve atlas family lock") + _expect( + parsed_restored_config.get("restore_prompt_axes") == [ + "clothing_detail", + "face_detail", + "expression_detail", + "mouth_detail", + "reaction_detail", + ], + "Krea2 POV Prompt Restore should record requested prompt axes", + ) + _expect( + parsed_restored_config.get("relax_non_pose_axis_conflicts") is True, + "Krea2 POV Prompt Restore should enable non-pose conflict relaxation", + ) + _expect("restore_axes=clothing_detail,face_detail" in restored_summary, "Krea2 POV Prompt Restore summary lost restored axes") + parsed_restored = hardcore_position_config.parse_hardcore_position_config(parsed_restored_config) + conflicting_clothing = {"text": "shirt pulled open while standing", "position_keys": ["standing"]} + neutral_clothing = {"text": "shirt pulled open at the hips"} + restored_clothing = hardcore_position_config.filter_hardcore_axis( + "clothing_detail", + [conflicting_clothing, neutral_clothing], + parsed_restored, + ) + _expect( + restored_clothing == [conflicting_clothing, neutral_clothing], + "Krea2 POV Prompt Restore should allow restored clothing detail through non-pose conflict pruning", + ) + unrestored_hand = hardcore_position_config.filter_hardcore_axis( + "hand_detail", + [conflicting_clothing, neutral_clothing], + parsed_restored, + ) + _expect( + unrestored_hand == [neutral_clothing], + "Krea2 POV Prompt Restore should not relax unrequested non-pose axes", + ) + restored_position = hardcore_position_config.filter_hardcore_axis( + "position", + [ + {"text": "standing sex position", "position_keys": ["standing"]}, + {"text": "doggy style position", "position_keys": ["doggy"]}, + ], + parsed_restored, + ) + _expect( + restored_position == [{"text": "doggy style position", "position_keys": ["doggy"]}], + "Krea2 POV Prompt Restore should keep position axes locked to the atlas pose", + ) + evidence_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPKrea2VariantEvidence"] evidence_inputs = evidence_node.INPUT_TYPES().get("required") or {} _expect("variant_key" in evidence_inputs, "Krea2 Variant Evidence lost variant selector")