diff --git a/README.md b/README.md index cc90b5c..82192db 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ The node is registered as: - `prompt_builder / SxCP Prompt Builder` - `prompt_builder / SxCP Seed Control` - `prompt_builder / SxCP Caption Naturalizer` +- `prompt_builder / SxCP Insta/OF Options` +- `prompt_builder / SxCP Insta/OF Prompt Pair` It outputs: @@ -41,6 +43,41 @@ It outputs: - `natural_caption` - `method` +`SxCP Insta/OF Prompt Pair` is a special paired-output mode. It creates one +shared primary creator descriptor, then returns both a softcore prompt and a +hardcore prompt from that same descriptor. This is useful when you want the same +person/look/scene continuity but need two different prompt strengths. + +It outputs: + +- `softcore_prompt` +- `hardcore_prompt` +- `softcore_negative_prompt` +- `hardcore_negative_prompt` +- `softcore_caption` +- `hardcore_caption` +- `shared_descriptor` +- `metadata_json` + +`SxCP Insta/OF Options` outputs `options_json`, which can be connected to the +pair node. Defaults are set so the softcore prompt is solo while the hardcore +prompt can include partners. + +Options: + +- `softcore_cast`: `solo` or `same_as_hardcore`. +- `hardcore_cast`: `use_counts`, `couple`, `threesome`, or `group`. +- `hardcore_women_count` and `hardcore_men_count`: used when `hardcore_cast` is + `use_counts`. The pair mode always keeps at least one adult woman as the + primary creator so the shared descriptor remains valid. +- `softcore_level`: `social_tease`, `lingerie_tease`, `implied_nude`, or + `explicit_tease`. +- `hardcore_level`: `explicit` or `hardcore`. +- `platform_style`: `hybrid`, `instagram`, or `onlyfans`. +- `continuity`: `same_creator_same_room` keeps the scene/composition aligned; + `same_creator_new_scene` keeps the same creator descriptor but lets the + hardcore scene use its own setting. + ## Built-In Categories The node keeps the original generator controls: diff --git a/__init__.py b/__init__.py index c3f1d46..d17cb45 100644 --- a/__init__.py +++ b/__init__.py @@ -3,10 +3,24 @@ from __future__ import annotations import json try: - from .prompt_builder import build_prompt, build_seed_config_json, category_choices, subcategory_choices + from .prompt_builder import ( + build_insta_of_options_json, + build_insta_of_pair, + build_prompt, + build_seed_config_json, + category_choices, + subcategory_choices, + ) from .caption_naturalizer import naturalize_caption except ImportError: - from prompt_builder import build_prompt, build_seed_config_json, category_choices, subcategory_choices + from prompt_builder import ( + build_insta_of_options_json, + build_insta_of_pair, + build_prompt, + build_seed_config_json, + category_choices, + subcategory_choices, + ) from caption_naturalizer import naturalize_caption @@ -196,14 +210,144 @@ class SxCPCaptionNaturalizer: ) +class SxCPInstaOFOptions: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "softcore_cast": (["solo", "same_as_hardcore"], {"default": "solo"}), + "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}), + "softcore_level": (["social_tease", "lingerie_tease", "implied_nude", "explicit_tease"], {"default": "lingerie_tease"}), + "hardcore_level": (["explicit", "hardcore"], {"default": "hardcore"}), + "platform_style": (["hybrid", "instagram", "onlyfans"], {"default": "hybrid"}), + "continuity": (["same_creator_same_room", "same_creator_new_scene"], {"default": "same_creator_same_room"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("options_json",) + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + softcore_cast, + hardcore_cast, + hardcore_women_count, + hardcore_men_count, + softcore_level, + hardcore_level, + platform_style, + continuity, + ): + return ( + build_insta_of_options_json( + softcore_cast=softcore_cast, + hardcore_cast=hardcore_cast, + hardcore_women_count=hardcore_women_count, + hardcore_men_count=hardcore_men_count, + softcore_level=softcore_level, + hardcore_level=hardcore_level, + platform_style=platform_style, + continuity=continuity, + ), + ) + + +class SxCPInstaOFPromptPair: + @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}), + "ethnicity": (["any", "asian", "white_asian"], {"default": "any"}), + "figure": (["curvy", "balanced", "bombshell"], {"default": "curvy"}), + "no_plus_women": ("BOOLEAN", {"default": False}), + "no_black": ("BOOLEAN", {"default": False}), + "trigger": ("STRING", {"default": "sxcpinup_coloredpencil"}), + "prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}), + }, + "optional": { + "seed_config": ("STRING", {"default": "", "multiline": True}), + "options_json": ("STRING", {"default": "", "multiline": True}), + "extra_positive": ("STRING", {"default": "", "multiline": True}), + "extra_negative": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ( + "softcore_prompt", + "hardcore_prompt", + "softcore_negative_prompt", + "hardcore_negative_prompt", + "softcore_caption", + "hardcore_caption", + "shared_descriptor", + "metadata_json", + ) + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + row_number, + start_index, + seed, + ethnicity, + figure, + no_plus_women, + no_black, + trigger, + prepend_trigger_to_prompt, + seed_config="", + options_json="", + extra_positive="", + extra_negative="", + ): + row = build_insta_of_pair( + row_number=row_number, + start_index=start_index, + seed=seed, + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + trigger=trigger, + prepend_trigger_to_prompt=prepend_trigger_to_prompt, + seed_config=seed_config or "", + options_json=options_json or "", + extra_positive=extra_positive or "", + extra_negative=extra_negative or "", + ) + return ( + row["softcore_prompt"], + row["hardcore_prompt"], + row["softcore_negative_prompt"], + row["hardcore_negative_prompt"], + row["softcore_caption"], + row["hardcore_caption"], + row["shared_descriptor"], + json.dumps(row, ensure_ascii=True, sort_keys=True), + ) + + NODE_CLASS_MAPPINGS = { "SxCPPromptBuilder": SxCPPromptBuilder, "SxCPSeedControl": SxCPSeedControl, "SxCPCaptionNaturalizer": SxCPCaptionNaturalizer, + "SxCPInstaOFOptions": SxCPInstaOFOptions, + "SxCPInstaOFPromptPair": SxCPInstaOFPromptPair, } NODE_DISPLAY_NAME_MAPPINGS = { "SxCPPromptBuilder": "SxCP Prompt Builder", "SxCPSeedControl": "SxCP Seed Control", "SxCPCaptionNaturalizer": "SxCP Caption Naturalizer", + "SxCPInstaOFOptions": "SxCP Insta/OF Options", + "SxCPInstaOFPromptPair": "SxCP Insta/OF Prompt Pair", } diff --git a/prompt_builder.py b/prompt_builder.py index 3dc8f17..960a4c5 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1267,6 +1267,7 @@ def _build_custom_row( "style": style, "item": item_text, "item_label": item_label, + "positive_suffix": positive_suffix, "custom_item": item_name, "item_axis_values": item_axis_values, "scene_text": scene, @@ -1379,3 +1380,266 @@ def build_prompt( row["negative_prompt"] = _combined_negative(row.get("negative_prompt", g.NEGATIVE_PROMPT), extra_negative) row["trigger"] = active_trigger return row + + +INSTA_OF_SOFT_LEVELS = { + "social_tease": "Instagram-style thirst-trap post, suggestive but non-explicit, polished social feed energy", + "lingerie_tease": "premium OF teaser set, lingerie-focused, sensual and intimate but without explicit sex", + "implied_nude": "implied nude creator set, strategically covered body, sensual but no visible sex act", + "explicit_tease": "explicit adult tease, nudity can be visible, but no penetration or partnered sex act", +} + +INSTA_OF_HARDCORE_LEVELS = { + "explicit": "explicit adult creator content with clear sexual contact and adult-only framing", + "hardcore": "hardcore adult creator content with anatomically clear sexual contact and intense body language", +} + +INSTA_OF_PLATFORM_STYLES = { + "hybrid": "hybrid Instagram-to-OF creator shoot, polished social-media framing with intimate subscriber-content energy", + "instagram": "Instagram-inspired creator shoot, polished mirror-selfie and feed-post aesthetics", + "onlyfans": "OnlyFans-inspired creator shoot, intimate subscriber-view camera and candid premium-content framing", +} + +INSTA_OF_NEGATIVE = ( + "minors, childlike appearance, teen, underage, schoolgirl, non-consensual, coercion, rape, " + "violence, injury, blood, gore, incest, bestiality, watermark, logo, readable username, social media UI" +) + +INSTA_OF_SOFT_NEGATIVE = ( + INSTA_OF_NEGATIVE + ", explicit intercourse, penetration, oral sex, cumshot, genital contact, group sex" +) + + +def build_insta_of_options_json( + softcore_cast: str = "solo", + hardcore_cast: str = "use_counts", + hardcore_women_count: int = 1, + hardcore_men_count: int = 1, + softcore_level: str = "lingerie_tease", + hardcore_level: str = "hardcore", + platform_style: str = "hybrid", + continuity: str = "same_creator_same_room", +) -> str: + return json.dumps( + { + "softcore_cast": softcore_cast, + "hardcore_cast": hardcore_cast, + "hardcore_women_count": int(hardcore_women_count), + "hardcore_men_count": int(hardcore_men_count), + "softcore_level": softcore_level, + "hardcore_level": hardcore_level, + "platform_style": platform_style, + "continuity": continuity, + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[str, Any]: + defaults = { + "softcore_cast": "solo", + "hardcore_cast": "use_counts", + "hardcore_women_count": 1, + "hardcore_men_count": 1, + "softcore_level": "lingerie_tease", + "hardcore_level": "hardcore", + "platform_style": "hybrid", + "continuity": "same_creator_same_room", + } + if not options_json: + return defaults + if isinstance(options_json, dict): + raw = options_json + else: + try: + raw = json.loads(str(options_json)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid Insta/OF options JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("Insta/OF options must be a JSON object") + parsed = {**defaults, **raw} + parsed["softcore_cast"] = parsed["softcore_cast"] if parsed["softcore_cast"] in ("solo", "same_as_hardcore") else defaults["softcore_cast"] + parsed["hardcore_cast"] = parsed["hardcore_cast"] if parsed["hardcore_cast"] in ("use_counts", "couple", "threesome", "group") else defaults["hardcore_cast"] + parsed["softcore_level"] = parsed["softcore_level"] if parsed["softcore_level"] in INSTA_OF_SOFT_LEVELS else defaults["softcore_level"] + parsed["hardcore_level"] = parsed["hardcore_level"] if parsed["hardcore_level"] in INSTA_OF_HARDCORE_LEVELS else defaults["hardcore_level"] + parsed["platform_style"] = parsed["platform_style"] if parsed["platform_style"] in INSTA_OF_PLATFORM_STYLES else defaults["platform_style"] + parsed["continuity"] = parsed["continuity"] if parsed["continuity"] in ("same_creator_same_room", "same_creator_new_scene") else defaults["continuity"] + for key in ("hardcore_women_count", "hardcore_men_count"): + try: + parsed[key] = max(0, min(12, int(parsed[key]))) + except (TypeError, ValueError): + parsed[key] = defaults[key] + return parsed + + +def _insta_of_hardcore_counts(options: dict[str, Any]) -> tuple[int, int]: + policy = str(options.get("hardcore_cast", "use_counts")) + if policy == "couple": + women_count, men_count = 1, 1 + elif policy == "threesome": + women_count, men_count = 2, 1 + elif policy == "group": + women_count, men_count = 3, 2 + else: + women_count = int(options.get("hardcore_women_count") or 0) + men_count = int(options.get("hardcore_men_count") or 0) + women_count = max(1, min(12, women_count)) + men_count = max(0, min(12, men_count)) + if women_count + men_count < 2: + men_count = 1 + return women_count, men_count + + +def _insta_of_descriptor(row: dict[str, Any]) -> str: + age = str(row.get("age_band") or row.get("age") or "").strip() + age = " ".join(age.split()) + age = age.removesuffix(" adults").removesuffix(" adult").strip() + pieces = [ + f"{age} adult woman" if age else "adult woman", + row.get("body_phrase"), + row.get("skin"), + row.get("hair"), + row.get("eyes"), + ] + return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + + +def _insta_of_cast_phrase(women_count: int, men_count: int) -> str: + context = _configured_cast_context(women_count, men_count) + return context["cast_summary"] + + +def _insta_of_active_trigger(prompt: str, trigger: str, enabled: bool) -> str: + return _prepend_trigger(prompt, trigger, enabled) + + +def build_insta_of_pair( + row_number: int, + start_index: int, + seed: int, + ethnicity: str, + figure: str, + no_plus_women: bool, + no_black: bool, + trigger: str, + prepend_trigger_to_prompt: bool, + seed_config: str | dict[str, Any] | None = None, + options_json: str | dict[str, Any] | None = None, + extra_positive: str = "", + extra_negative: str = "", +) -> dict[str, Any]: + options = _parse_insta_of_options(options_json) + hard_women_count, hard_men_count = _insta_of_hardcore_counts(options) + active_trigger = trigger.strip() or g.TRIGGER + parsed_seed_config = _parse_seed_config(seed_config) + + soft_row = build_prompt( + category="Provocative erotic clothes", + subcategory=RANDOM_SUBCATEGORY, + row_number=row_number, + start_index=start_index, + seed=seed, + clothing="minimal", + ethnicity=ethnicity, + poses="evocative", + backside_bias=0.0, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + minimal_clothing_ratio=-1, + standard_pose_ratio=-1, + trigger=active_trigger, + prepend_trigger_to_prompt=False, + extra_positive="", + extra_negative="", + seed_config=parsed_seed_config, + women_count=1, + men_count=0, + ) + hard_row = build_prompt( + category="Hardcore sexual poses", + subcategory=RANDOM_SUBCATEGORY, + row_number=row_number, + start_index=start_index, + seed=seed, + clothing="minimal", + ethnicity=ethnicity, + poses="evocative", + backside_bias=0.0, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + minimal_clothing_ratio=-1, + standard_pose_ratio=-1, + trigger=active_trigger, + prepend_trigger_to_prompt=False, + extra_positive="", + extra_negative="", + seed_config=parsed_seed_config, + women_count=hard_women_count, + men_count=hard_men_count, + ) + + descriptor = _insta_of_descriptor(soft_row) + platform_style = INSTA_OF_PLATFORM_STYLES[options["platform_style"]] + soft_level = INSTA_OF_SOFT_LEVELS[options["softcore_level"]] + hard_level = INSTA_OF_HARDCORE_LEVELS[options["hardcore_level"]] + hard_scene = soft_row["scene_text"] if options["continuity"] == "same_creator_same_room" else hard_row["scene_text"] + hard_composition = soft_row["composition"] if options["continuity"] == "same_creator_same_room" else hard_row["composition"] + soft_cast = ( + "solo creator setup; the primary creator is alone in the softcore version" + if options["softcore_cast"] == "solo" + else f"non-explicit teaser setup with the same adult cast as the hardcore version: {_insta_of_cast_phrase(hard_women_count, hard_men_count)}" + ) + hard_cast = _insta_of_cast_phrase(hard_women_count, hard_men_count) + + soft_prompt = ( + f"Insta/OF softcore mode: {platform_style}. Shared primary creator descriptor: {descriptor}. " + f"Softcore setup: {soft_level}. Cast continuity: {soft_cast}. " + f"Outfit: {soft_row['item']}. Pose: {soft_row['pose']}. Setting: {soft_row['scene_text']}. " + f"Facial expression: {soft_row['expression']}. Composition: {soft_row['composition']}. " + "Keep the softcore version adult-only, consensual, seductive, creator-shot, and non-explicit. " + f"{soft_row['positive_suffix']} Avoid: {INSTA_OF_SOFT_NEGATIVE}." + ) + hard_prompt = ( + f"Insta/OF hardcore mode: {platform_style}. Shared primary creator descriptor: {descriptor}. " + f"Hardcore setup: {hard_level}. Cast: {hard_cast}. " + "Apply the shared descriptor to the most visually central woman, keeping her continuous with the softcore version. " + f"Role graph: {hard_row['role_graph']} Sexual scene: {hard_row['item']}. " + f"Setting: {hard_scene}. Facial expressions: {hard_row['expression']}. Composition: {hard_composition}. " + "All participants are consenting adults 21+. " + f"{hard_row['positive_suffix']} Avoid: {INSTA_OF_NEGATIVE}." + ) + if extra_positive.strip(): + soft_prompt = f"{soft_prompt.rstrip()} {extra_positive.strip()}" + hard_prompt = f"{hard_prompt.rstrip()} {extra_positive.strip()}" + + soft_prompt = _insta_of_active_trigger(soft_prompt, active_trigger, bool(prepend_trigger_to_prompt)) + hard_prompt = _insta_of_active_trigger(hard_prompt, active_trigger, bool(prepend_trigger_to_prompt)) + soft_negative = _combined_negative(INSTA_OF_SOFT_NEGATIVE, extra_negative) + hard_negative = _combined_negative(INSTA_OF_NEGATIVE, extra_negative) + soft_caption = ( + f"{active_trigger}, Insta/OF softcore mode, {descriptor}, {soft_level}, " + f"{soft_row['item']}, {soft_row['pose']}, {soft_row['scene_text']}, {soft_row['composition']}" + ) + hard_caption = ( + f"{active_trigger}, Insta/OF hardcore mode, same primary creator descriptor, {descriptor}, " + f"{hard_cast}, {hard_row['role_graph']}, {hard_row['item']}, {hard_scene}, {hard_composition}" + ) + metadata = { + "mode": "Insta/OF", + "options": options, + "shared_descriptor": descriptor, + "softcore_prompt": soft_prompt, + "hardcore_prompt": hard_prompt, + "softcore_negative_prompt": soft_negative, + "hardcore_negative_prompt": hard_negative, + "softcore_caption": soft_caption, + "hardcore_caption": hard_caption, + "softcore_row": soft_row, + "hardcore_row": hard_row, + "hardcore_women_count": hard_women_count, + "hardcore_men_count": hard_men_count, + } + return metadata