From 9c72af0585cfffcb3f3cb596e39f6249e6570fef Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 12:40:34 +0200 Subject: [PATCH] Add split workflow nodes and profile controls --- README.md | 74 +++ __init__.py | 334 +++++++++++++ examples/split_profile_reuse_workflow.json | 293 +++++++++++ profiles/.gitignore | 2 + prompt_builder.py | 549 +++++++++++++++++++++ web/profile_buttons.js | 112 +++++ 6 files changed, 1364 insertions(+) create mode 100644 examples/split_profile_reuse_workflow.json create mode 100644 profiles/.gitignore create mode 100644 web/profile_buttons.js diff --git a/README.md b/README.md index fd28edb..6c1c183 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ The node is registered as: - `prompt_builder / SxCP Prompt Builder` - `prompt_builder / SxCP Seed Control` - `prompt_builder / SxCP Camera Control` +- `prompt_builder / SxCP Category Preset` +- `prompt_builder / SxCP Cast Control` +- `prompt_builder / SxCP Generation Profile` +- `prompt_builder / SxCP Advanced Filters` +- `prompt_builder / SxCP Prompt Builder From Configs` +- `prompt_builder / SxCP Character Profile Save` +- `prompt_builder / SxCP Character Profile Load` - `prompt_builder / SxCP Caption Naturalizer` - `prompt_builder / SxCP Krea2 Formatter` - `prompt_builder / SxCP Insta/OF Options` @@ -22,6 +29,71 @@ It outputs: - `category` - `subcategory` +## Split Workflow Nodes + +The original `SxCP Prompt Builder` remains available as the full all-in-one +node. For cleaner workflows, use the split nodes: + +- `SxCP Category Preset` outputs `category_config` for broad intent such as + women casual, men casual, couple casual, provocative erotic, or hardcore pose. +- `SxCP Cast Control` outputs `cast_config` plus raw `women_count` and + `men_count`, so couple/two-women/two-men/group setups can be reused. +- `SxCP Generation Profile` outputs `generation_profile` for common behavior + presets such as casual-clean, evocative-softcore, hardcore-intense, + Krea2-friendly, or Flux-original. +- `SxCP Advanced Filters` outputs `filter_config` for ethnicity, figure, and + exclusion filters. +- `SxCP Prompt Builder From Configs` consumes those config outputs and produces + the same prompt, negative, caption, metadata, category, and subcategory + outputs as the full builder. + +The practical compact workflow is: + +`Category Preset` + `Cast Control` + `Generation Profile` + optional +`Advanced Filters`, `Seed Control`, `Camera Control`, and `Character Profile` +into `Prompt Builder From Configs`. + +An importable example is included at +`examples/split_profile_reuse_workflow.json`. It shows a two-pass setup: + +1. Generate a casual woman prompt and extract a character profile from + `metadata_json`. +2. Reuse that profile in a second prompt builder with a different seed, keeping + the same character while changing the outfit/scene. +3. Send the reused-profile metadata to both `Caption Naturalizer` and + `Krea2 Formatter`. + +## Character Profiles + +`SxCP Character Profile Save` extracts a reusable woman/man profile from +`metadata_json` or from manual fields. The profile stores age, body/body phrase, +skin, hair, eyes, figure, and subject type. It only writes a file when +the `Save Profile Now` button is clicked; otherwise it just outputs profile JSON +for direct wiring. Saved files are written under `profiles/.json`; +saved profile files are ignored by git. The button is backed by the hidden +`save_now` trigger and queues the workflow once. + +`SxCP Character Profile Load` has an `enabled` switch. When disabled, it returns +an empty profile so connected prompt builders ignore it. When enabled, it loads +a saved profile by selector or passes through a connected fallback profile JSON. +It also has explicit file-operation triggers: + +- `Delete Selected Profile`: deletes the selected saved profile. +- `Rename Selected Profile` + `rename_to`: renames the selected saved profile. + +Delete and rename are conservative: if both triggers are enabled together, +nothing happens; rename does not overwrite an existing target profile. The +buttons are backed by hidden `delete_now` and `rename_now` triggers and queue +the workflow once. + +Connect the loader's `character_profile` output to `SxCP Prompt Builder`, +`SxCP Prompt Builder From Configs`, or `SxCP Insta/OF Prompt Pair`. + +Profile reuse currently applies to structured JSON-category single woman/man +rows and to the primary creator in Insta/OF pair mode. The outfit, scene, pose, +expression, and composition can still change while the saved character +appearance remains stable. + `SxCP Seed Control` outputs `seed_config`, which can be connected to the prompt builder's optional `seed_config` input. @@ -190,6 +262,8 @@ or reload ComfyUI after changing JSON so dropdown choices are rebuilt. Included JSON categories: - `Casual clothes` +- `Men casual clothes` +- `Couple casual clothes` - `Provocative erotic clothes` - `Hardcore sexual poses` diff --git a/__init__.py b/__init__.py index 0b3147b..2b0726d 100644 --- a/__init__.py +++ b/__init__.py @@ -5,9 +5,15 @@ import json try: from .prompt_builder import ( build_camera_config_json, + build_cast_config_json, + build_category_config_json, + build_character_profile_json, + build_filter_config_json, + build_generation_profile_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, + build_prompt_from_configs, build_seed_config_json, camera_angle_choices, camera_distance_choices, @@ -17,7 +23,12 @@ try: camera_phone_choices, camera_priority_choices, camera_shot_choices, + cast_preset_choices, + category_preset_choices, category_choices, + character_profile_choices, + generation_profile_choices, + load_character_profile_json, subcategory_choices, ) from .caption_naturalizer import naturalize_caption @@ -25,9 +36,15 @@ try: except ImportError: from prompt_builder import ( build_camera_config_json, + build_cast_config_json, + build_category_config_json, + build_character_profile_json, + build_filter_config_json, + build_generation_profile_json, build_insta_of_options_json, build_insta_of_pair, build_prompt, + build_prompt_from_configs, build_seed_config_json, camera_angle_choices, camera_distance_choices, @@ -37,7 +54,12 @@ except ImportError: camera_phone_choices, camera_priority_choices, camera_shot_choices, + cast_preset_choices, + category_preset_choices, category_choices, + character_profile_choices, + generation_profile_choices, + load_character_profile_json, subcategory_choices, ) from caption_naturalizer import naturalize_caption @@ -72,6 +94,7 @@ class SxCPPromptBuilder: "optional": { "seed_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), + "character_profile": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -105,6 +128,7 @@ class SxCPPromptBuilder: prepend_trigger_to_prompt, seed_config="", camera_config="", + character_profile="", extra_positive="", extra_negative="", ): @@ -132,6 +156,7 @@ class SxCPPromptBuilder: extra_negative=extra_negative or "", seed_config=seed_config or "", camera_config=camera_config or "", + character_profile=character_profile or "", ) return ( row["prompt"], @@ -239,6 +264,294 @@ class SxCPCameraControl: ) +class SxCPCategoryPreset: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "preset": (category_preset_choices(), {"default": "auto_weighted"}), + "subcategory": (subcategory_choices(), {"default": "random"}), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("category_config", "category", "subcategory") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, preset, subcategory): + config = build_category_config_json(preset=preset, subcategory=subcategory) + parsed = json.loads(config) + return config, parsed["category"], parsed["subcategory"] + + +class SxCPCastControl: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "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}), + } + } + + RETURN_TYPES = ("STRING", "INT", "INT", "STRING") + RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, cast_mode, women_count, men_count): + config = build_cast_config_json(cast_mode=cast_mode, women_count=women_count, men_count=men_count) + parsed = json.loads(config) + summary = f"{parsed['women_count']} women, {parsed['men_count']} men" + return config, parsed["women_count"], parsed["men_count"], summary + + +class SxCPGenerationProfile: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "profile": (generation_profile_choices(), {"default": "balanced"}), + "clothing_override": (["profile_default", "full", "minimal"], {"default": "profile_default"}), + "poses_override": (["profile_default", "standard", "evocative"], {"default": "profile_default"}), + "expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "backside_bias": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "minimal_clothing_ratio": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "standard_pose_ratio": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + "trigger_policy": (["profile_default", "prepend_trigger", "do_not_prepend"], {"default": "profile_default"}), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("generation_profile", "summary") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + profile, + clothing_override, + poses_override, + expression_intensity, + backside_bias, + minimal_clothing_ratio, + standard_pose_ratio, + trigger_policy, + ): + config = build_generation_profile_json( + profile=profile, + clothing_override=clothing_override, + poses_override=poses_override, + expression_intensity=expression_intensity, + backside_bias=backside_bias, + minimal_clothing_ratio=minimal_clothing_ratio, + standard_pose_ratio=standard_pose_ratio, + trigger_policy=trigger_policy, + ) + parsed = json.loads(config) + summary = f"{parsed['profile']}: {parsed['clothing']}, {parsed['poses']}, expression {parsed['expression_intensity']}" + return config, summary + + +class SxCPAdvancedFilters: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "ethnicity": (["any", "asian", "white_asian"], {"default": "any"}), + "figure": (["curvy", "balanced", "bombshell"], {"default": "curvy"}), + "no_plus_women": ("BOOLEAN", {"default": False}), + "no_black": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("filter_config",) + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build(self, ethnicity, figure, no_plus_women, no_black): + return ( + build_filter_config_json( + ethnicity=ethnicity, + figure=figure, + no_plus_women=no_plus_women, + no_black=no_black, + ), + ) + + +class SxCPPromptBuilderFromConfigs: + @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}), + }, + "optional": { + "category_config": ("STRING", {"default": "", "multiline": True}), + "cast_config": ("STRING", {"default": "", "multiline": True}), + "generation_profile": ("STRING", {"default": "", "multiline": True}), + "filter_config": ("STRING", {"default": "", "multiline": True}), + "seed_config": ("STRING", {"default": "", "multiline": True}), + "camera_config": ("STRING", {"default": "", "multiline": True}), + "character_profile": ("STRING", {"default": "", "multiline": True}), + "extra_positive": ("STRING", {"default": "", "multiline": True}), + "extra_negative": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("prompt", "negative_prompt", "caption", "metadata_json", "category", "subcategory") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + row_number, + start_index, + seed, + category_config="", + cast_config="", + generation_profile="", + filter_config="", + seed_config="", + camera_config="", + character_profile="", + extra_positive="", + extra_negative="", + ): + row = build_prompt_from_configs( + row_number=row_number, + start_index=start_index, + seed=seed, + category_config=category_config or "", + cast_config=cast_config or "", + generation_profile=generation_profile or "", + filter_config=filter_config or "", + seed_config=seed_config or "", + camera_config=camera_config or "", + character_profile=character_profile or "", + extra_positive=extra_positive or "", + extra_negative=extra_negative or "", + ) + return ( + row["prompt"], + row["negative_prompt"], + row["caption"], + json.dumps(row, ensure_ascii=True, sort_keys=True), + row.get("main_category", ""), + row.get("subcategory", ""), + ) + + +class SxCPCharacterProfileSave: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "profile_name": ("STRING", {"default": "saved_character"}), + "source": (["metadata_json", "manual"], {"default": "metadata_json"}), + "subject_type": (["woman", "man"], {"default": "woman"}), + "age": ("STRING", {"default": ""}), + "body": ("STRING", {"default": ""}), + "body_phrase": ("STRING", {"default": ""}), + "skin": ("STRING", {"default": ""}), + "hair": ("STRING", {"default": ""}), + "eyes": ("STRING", {"default": ""}), + "figure": ("STRING", {"default": ""}), + "save_now": ("BOOLEAN", {"default": False}), + }, + "optional": { + "metadata_json": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("character_profile", "descriptor", "profile_name", "saved_path", "status") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + profile_name, + source, + subject_type, + age, + body, + body_phrase, + skin, + hair, + eyes, + figure, + save_now, + metadata_json="", + ): + profile = build_character_profile_json( + profile_name=profile_name, + source=source, + metadata_json=metadata_json or "", + subject_type=subject_type, + age=age, + body=body, + body_phrase=body_phrase, + skin=skin, + hair=hair, + eyes=eyes, + figure=figure, + save_now=save_now, + ) + return profile["profile_json"], profile["descriptor"], profile["profile_name"], profile["saved_path"], profile["status"] + + +class SxCPCharacterProfileLoad: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "enabled": ("BOOLEAN", {"default": True}), + "profile_name": (character_profile_choices(), {"default": "manual"}), + "rename_to": ("STRING", {"default": ""}), + "delete_now": ("BOOLEAN", {"default": False}), + "rename_now": ("BOOLEAN", {"default": False}), + }, + "optional": { + "manual_profile_name": ("STRING", {"default": ""}), + "fallback_profile_json": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING") + RETURN_NAMES = ("character_profile", "descriptor", "profile_name", "saved_path", "status") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + enabled, + profile_name, + rename_to, + delete_now, + rename_now, + manual_profile_name="", + fallback_profile_json="", + ): + chosen_name = manual_profile_name.strip() if profile_name == "manual" and manual_profile_name.strip() else profile_name + profile = load_character_profile_json( + profile_name=chosen_name, + fallback_profile_json=fallback_profile_json or "", + enabled=enabled, + delete_now=delete_now, + rename_now=rename_now, + rename_to=rename_to, + ) + return profile["profile_json"], profile["descriptor"], profile["profile_name"], profile["saved_path"], profile["status"] + + class SxCPCaptionNaturalizer: @classmethod def INPUT_TYPES(cls): @@ -428,6 +741,7 @@ class SxCPInstaOFPromptPair: "seed_config": ("STRING", {"default": "", "multiline": True}), "options_json": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}), + "character_profile": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}), }, @@ -461,6 +775,7 @@ class SxCPInstaOFPromptPair: seed_config="", options_json="", camera_config="", + character_profile="", extra_positive="", extra_negative="", ): @@ -477,6 +792,7 @@ class SxCPInstaOFPromptPair: seed_config=seed_config or "", options_json=options_json or "", camera_config=camera_config or "", + character_profile=character_profile or "", extra_positive=extra_positive or "", extra_negative=extra_negative or "", ) @@ -496,6 +812,13 @@ NODE_CLASS_MAPPINGS = { "SxCPPromptBuilder": SxCPPromptBuilder, "SxCPSeedControl": SxCPSeedControl, "SxCPCameraControl": SxCPCameraControl, + "SxCPCategoryPreset": SxCPCategoryPreset, + "SxCPCastControl": SxCPCastControl, + "SxCPGenerationProfile": SxCPGenerationProfile, + "SxCPAdvancedFilters": SxCPAdvancedFilters, + "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, + "SxCPCharacterProfileSave": SxCPCharacterProfileSave, + "SxCPCharacterProfileLoad": SxCPCharacterProfileLoad, "SxCPCaptionNaturalizer": SxCPCaptionNaturalizer, "SxCPKrea2Formatter": SxCPKrea2Formatter, "SxCPInstaOFOptions": SxCPInstaOFOptions, @@ -506,8 +829,19 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPPromptBuilder": "SxCP Prompt Builder", "SxCPSeedControl": "SxCP Seed Control", "SxCPCameraControl": "SxCP Camera Control", + "SxCPCategoryPreset": "SxCP Category Preset", + "SxCPCastControl": "SxCP Cast Control", + "SxCPGenerationProfile": "SxCP Generation Profile", + "SxCPAdvancedFilters": "SxCP Advanced Filters", + "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", + "SxCPCharacterProfileSave": "SxCP Character Profile Save", + "SxCPCharacterProfileLoad": "SxCP Character Profile Load", "SxCPCaptionNaturalizer": "SxCP Caption Naturalizer", "SxCPKrea2Formatter": "SxCP Krea2 Formatter", "SxCPInstaOFOptions": "SxCP Insta/OF Options", "SxCPInstaOFPromptPair": "SxCP Insta/OF Prompt Pair", } + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/examples/split_profile_reuse_workflow.json b/examples/split_profile_reuse_workflow.json new file mode 100644 index 0000000..f4cd081 --- /dev/null +++ b/examples/split_profile_reuse_workflow.json @@ -0,0 +1,293 @@ +{ + "last_node_id": 12, + "last_link_id": 17, + "nodes": [ + { + "id": 1, + "type": "SxCPCategoryPreset", + "pos": [-980, -300], + "size": [320, 82], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "category_config", "type": "STRING", "links": [1, 2], "slot_index": 0}, + {"name": "category", "type": "STRING", "links": null, "slot_index": 1}, + {"name": "subcategory", "type": "STRING", "links": null, "slot_index": 2} + ], + "properties": {"Node name for S&R": "SxCPCategoryPreset"}, + "widgets_values": ["women_casual", "Casual clothes / Smart casual"] + }, + { + "id": 2, + "type": "SxCPCastControl", + "pos": [-980, -160], + "size": [320, 106], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "cast_config", "type": "STRING", "links": [3, 4], "slot_index": 0}, + {"name": "women_count", "type": "INT", "links": null, "slot_index": 1}, + {"name": "men_count", "type": "INT", "links": null, "slot_index": 2}, + {"name": "cast_summary", "type": "STRING", "links": null, "slot_index": 3} + ], + "properties": {"Node name for S&R": "SxCPCastControl"}, + "widgets_values": ["solo_woman", 1, 0] + }, + { + "id": 3, + "type": "SxCPGenerationProfile", + "pos": [-980, 20], + "size": [320, 226], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "generation_profile", "type": "STRING", "links": [5, 6], "slot_index": 0}, + {"name": "summary", "type": "STRING", "links": null, "slot_index": 1} + ], + "properties": {"Node name for S&R": "SxCPGenerationProfile"}, + "widgets_values": ["casual_clean", "profile_default", "profile_default", -1.0, -1.0, -1.0, -1.0, "profile_default"] + }, + { + "id": 4, + "type": "SxCPAdvancedFilters", + "pos": [-980, 310], + "size": [320, 130], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "filter_config", "type": "STRING", "links": [7, 8], "slot_index": 0} + ], + "properties": {"Node name for S&R": "SxCPAdvancedFilters"}, + "widgets_values": ["any", "balanced", false, false] + }, + { + "id": 5, + "type": "SxCPSeedControl", + "pos": [-600, -300], + "size": [320, 250], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "seed_config", "type": "STRING", "links": [9, 10], "slot_index": 0} + ], + "properties": {"Node name for S&R": "SxCPSeedControl"}, + "widgets_values": [-1, -1, -1, -1, -1, -1, -1, -1, -1] + }, + { + "id": 6, + "type": "SxCPCameraControl", + "pos": [-600, 10], + "size": [320, 226], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "camera_config", "type": "STRING", "links": [11, 12], "slot_index": 0} + ], + "properties": {"Node name for S&R": "SxCPCameraControl"}, + "widgets_values": ["mirror_selfie", "full_body", "mirror_reflection", "smartphone_wide", "arm_length", "vertical_story", "phone_visible", "locked"] + }, + { + "id": 7, + "type": "SxCPPromptBuilderFromConfigs", + "pos": [-180, -300], + "size": [360, 246], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + {"name": "category_config", "type": "STRING", "link": 1}, + {"name": "cast_config", "type": "STRING", "link": 3}, + {"name": "generation_profile", "type": "STRING", "link": 5}, + {"name": "filter_config", "type": "STRING", "link": 7}, + {"name": "seed_config", "type": "STRING", "link": 9}, + {"name": "camera_config", "type": "STRING", "link": 11} + ], + "outputs": [ + {"name": "prompt", "type": "STRING", "links": null, "slot_index": 0}, + {"name": "negative_prompt", "type": "STRING", "links": null, "slot_index": 1}, + {"name": "caption", "type": "STRING", "links": null, "slot_index": 2}, + {"name": "metadata_json", "type": "STRING", "links": [13], "slot_index": 3}, + {"name": "category", "type": "STRING", "links": null, "slot_index": 4}, + {"name": "subcategory", "type": "STRING", "links": null, "slot_index": 5} + ], + "properties": {"Node name for S&R": "SxCPPromptBuilderFromConfigs"}, + "widgets_values": [1, 41, 20260624] + }, + { + "id": 8, + "type": "SxCPCharacterProfileSave", + "pos": [260, -300], + "size": [360, 294], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + {"name": "metadata_json", "type": "STRING", "link": 13} + ], + "outputs": [ + {"name": "character_profile", "type": "STRING", "links": [14], "slot_index": 0}, + {"name": "descriptor", "type": "STRING", "links": null, "slot_index": 1}, + {"name": "profile_name", "type": "STRING", "links": null, "slot_index": 2}, + {"name": "saved_path", "type": "STRING", "links": null, "slot_index": 3}, + {"name": "status", "type": "STRING", "links": null, "slot_index": 4} + ], + "properties": {"Node name for S&R": "SxCPCharacterProfileSave"}, + "widgets_values": ["example_creator", "metadata_json", "woman", "", "", "", "", "", "", "", false] + }, + { + "id": 9, + "type": "SxCPCharacterProfileLoad", + "pos": [700, -300], + "size": [340, 174], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + {"name": "fallback_profile_json", "type": "STRING", "link": 14} + ], + "outputs": [ + {"name": "character_profile", "type": "STRING", "links": [15], "slot_index": 0}, + {"name": "descriptor", "type": "STRING", "links": null, "slot_index": 1}, + {"name": "profile_name", "type": "STRING", "links": null, "slot_index": 2}, + {"name": "saved_path", "type": "STRING", "links": null, "slot_index": 3}, + {"name": "status", "type": "STRING", "links": null, "slot_index": 4} + ], + "properties": {"Node name for S&R": "SxCPCharacterProfileLoad"}, + "widgets_values": [true, "manual", "", false, false, ""] + }, + { + "id": 10, + "type": "SxCPPromptBuilderFromConfigs", + "pos": [1120, -300], + "size": [370, 270], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + {"name": "category_config", "type": "STRING", "link": 2}, + {"name": "cast_config", "type": "STRING", "link": 4}, + {"name": "generation_profile", "type": "STRING", "link": 6}, + {"name": "filter_config", "type": "STRING", "link": 8}, + {"name": "seed_config", "type": "STRING", "link": 10}, + {"name": "camera_config", "type": "STRING", "link": 12}, + {"name": "character_profile", "type": "STRING", "link": 15} + ], + "outputs": [ + {"name": "prompt", "type": "STRING", "links": null, "slot_index": 0}, + {"name": "negative_prompt", "type": "STRING", "links": null, "slot_index": 1}, + {"name": "caption", "type": "STRING", "links": null, "slot_index": 2}, + {"name": "metadata_json", "type": "STRING", "links": [16, 17], "slot_index": 3}, + {"name": "category", "type": "STRING", "links": null, "slot_index": 4}, + {"name": "subcategory", "type": "STRING", "links": null, "slot_index": 5} + ], + "properties": {"Node name for S&R": "SxCPPromptBuilderFromConfigs"}, + "widgets_values": [1, 41, 20260777] + }, + { + "id": 11, + "type": "SxCPCaptionNaturalizer", + "pos": [1560, -420], + "size": [360, 198], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + {"name": "metadata_json", "type": "STRING", "link": 17} + ], + "outputs": [ + {"name": "natural_caption", "type": "STRING", "links": null, "slot_index": 0}, + {"name": "method", "type": "STRING", "links": null, "slot_index": 1} + ], + "properties": {"Node name for S&R": "SxCPCaptionNaturalizer"}, + "widgets_values": ["", "metadata_json", "balanced", "drop_style_tail", "sxcppnl7", false] + }, + { + "id": 12, + "type": "SxCPKrea2Formatter", + "pos": [1560, -150], + "size": [380, 246], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + {"name": "metadata_json", "type": "STRING", "link": 16} + ], + "outputs": [ + {"name": "krea_prompt", "type": "STRING", "links": null, "slot_index": 0}, + {"name": "negative_prompt", "type": "STRING", "links": null, "slot_index": 1}, + {"name": "krea_softcore_prompt", "type": "STRING", "links": null, "slot_index": 2}, + {"name": "krea_hardcore_prompt", "type": "STRING", "links": null, "slot_index": 3}, + {"name": "softcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 4}, + {"name": "hardcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 5}, + {"name": "method", "type": "STRING", "links": null, "slot_index": 6} + ], + "properties": {"Node name for S&R": "SxCPKrea2Formatter"}, + "widgets_values": ["", "metadata_json", "auto", "balanced", "minimal", false, "", "", ""] + } + ], + "links": [ + [1, 1, 0, 7, 0, "STRING"], + [2, 1, 0, 10, 0, "STRING"], + [3, 2, 0, 7, 1, "STRING"], + [4, 2, 0, 10, 1, "STRING"], + [5, 3, 0, 7, 2, "STRING"], + [6, 3, 0, 10, 2, "STRING"], + [7, 4, 0, 7, 3, "STRING"], + [8, 4, 0, 10, 3, "STRING"], + [9, 5, 0, 7, 4, "STRING"], + [10, 5, 0, 10, 4, "STRING"], + [11, 6, 0, 7, 5, "STRING"], + [12, 6, 0, 10, 5, "STRING"], + [13, 7, 3, 8, 0, "STRING"], + [14, 8, 0, 9, 0, "STRING"], + [15, 9, 0, 10, 6, "STRING"], + [16, 10, 3, 11, 0, "STRING"], + [17, 10, 3, 12, 0, "STRING"] + ], + "groups": [ + { + "title": "Shared controls", + "bounding": [-1010, -350, 760, 840], + "color": "#3f789e", + "font_size": 24 + }, + { + "title": "Pass 1: generate and extract profile", + "bounding": [-220, -350, 890, 390], + "color": "#8f6d31", + "font_size": 24 + }, + { + "title": "Pass 2: reuse profile with a different seed", + "bounding": [670, -350, 870, 390], + "color": "#4d7f45", + "font_size": 24 + }, + { + "title": "Optional reformats", + "bounding": [1530, -470, 440, 600], + "color": "#5f4d8f", + "font_size": 24 + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.82, + "offset": [1090, 430] + } + }, + "version": 0.4 +} diff --git a/profiles/.gitignore b/profiles/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/profiles/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/prompt_builder.py b/prompt_builder.py index 6faf9e0..126ebaf 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import random +import re from pathlib import Path from string import Formatter from typing import Any @@ -14,6 +15,7 @@ except ImportError: # Allows local smoke tests with `python -c`. ROOT_DIR = Path(__file__).resolve().parent CATEGORY_DIR = ROOT_DIR / "categories" +PROFILE_DIR = ROOT_DIR / "profiles" BUILTIN_CATEGORIES = [ "auto_weighted", @@ -688,6 +690,267 @@ def subcategory_choices() -> list[str]: return choices +CATEGORY_PRESETS = { + "auto_weighted": ("auto_weighted", RANDOM_SUBCATEGORY), + "women_casual": ("Casual clothes", RANDOM_SUBCATEGORY), + "men_casual": ("Men casual clothes", RANDOM_SUBCATEGORY), + "couple_casual": ("Couple casual clothes", RANDOM_SUBCATEGORY), + "provocative_erotic": ("Provocative erotic clothes", RANDOM_SUBCATEGORY), + "hardcore_pose": ("Hardcore sexual poses", RANDOM_SUBCATEGORY), + "custom_random": ("custom_random", RANDOM_SUBCATEGORY), +} + +CAST_PRESETS = { + "solo_woman": (1, 0), + "solo_man": (0, 1), + "mixed_couple": (1, 1), + "two_women": (2, 0), + "two_men": (0, 2), + "threesome_2w1m": (2, 1), + "small_group_3w2m": (3, 2), +} + +GENERATION_PROFILE_PRESETS = { + "balanced": { + "clothing": "full", + "poses": "standard", + "expression_intensity": 0.5, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "casual_clean": { + "clothing": "full", + "poses": "standard", + "expression_intensity": 0.35, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "evocative_softcore": { + "clothing": "minimal", + "poses": "evocative", + "expression_intensity": 0.65, + "backside_bias": 0.2, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "hardcore_intense": { + "clothing": "minimal", + "poses": "evocative", + "expression_intensity": 0.9, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, + "krea2_friendly": { + "clothing": "full", + "poses": "standard", + "expression_intensity": 0.55, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": False, + }, + "flux_original": { + "clothing": "full", + "poses": "standard", + "expression_intensity": 0.5, + "backside_bias": 0.0, + "minimal_clothing_ratio": -1.0, + "standard_pose_ratio": -1.0, + "trigger": "sxcpinup_coloredpencil", + "prepend_trigger_to_prompt": True, + }, +} + + +def category_preset_choices() -> list[str]: + return list(CATEGORY_PRESETS) + + +def cast_preset_choices() -> list[str]: + return list(CAST_PRESETS) + ["custom_counts"] + + +def generation_profile_choices() -> list[str]: + return list(GENERATION_PROFILE_PRESETS) + + +def build_category_config_json(preset: str = "auto_weighted", subcategory: str = RANDOM_SUBCATEGORY) -> str: + category, default_subcategory = CATEGORY_PRESETS.get(preset, CATEGORY_PRESETS["auto_weighted"]) + chosen_subcategory = subcategory if subcategory and subcategory != RANDOM_SUBCATEGORY else default_subcategory + return json.dumps( + { + "preset": preset if preset in CATEGORY_PRESETS else "auto_weighted", + "category": category, + "subcategory": chosen_subcategory, + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def _parse_category_config(category_config: str | dict[str, Any] | None) -> tuple[str, str]: + if not category_config: + return CATEGORY_PRESETS["auto_weighted"] + if isinstance(category_config, dict): + raw = category_config + else: + try: + raw = json.loads(str(category_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid category_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("category_config must be a JSON object") + preset = str(raw.get("preset") or "auto_weighted") + category, subcategory = CATEGORY_PRESETS.get(preset, CATEGORY_PRESETS["auto_weighted"]) + category = str(raw.get("category") or category) + subcategory = str(raw.get("subcategory") or subcategory or RANDOM_SUBCATEGORY) + return category, subcategory + + +def build_cast_config_json(cast_mode: str = "mixed_couple", women_count: int = 1, men_count: int = 1) -> str: + if cast_mode in CAST_PRESETS: + women_count, men_count = CAST_PRESETS[cast_mode] + else: + women_count = max(0, min(12, int(women_count))) + men_count = max(0, min(12, int(men_count))) + if women_count + men_count == 0: + women_count = 1 + cast_mode = "custom_counts" + return json.dumps( + { + "cast_mode": cast_mode, + "women_count": int(women_count), + "men_count": int(men_count), + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def _parse_cast_config(cast_config: str | dict[str, Any] | None) -> dict[str, int | str]: + if not cast_config: + return {"cast_mode": "mixed_couple", "women_count": 1, "men_count": 1} + if isinstance(cast_config, dict): + raw = cast_config + else: + try: + raw = json.loads(str(cast_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid cast_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("cast_config must be a JSON object") + return json.loads(build_cast_config_json(str(raw.get("cast_mode") or "custom_counts"), raw.get("women_count", 1), raw.get("men_count", 1))) + + +def build_generation_profile_json( + profile: str = "balanced", + clothing_override: str = "profile_default", + poses_override: str = "profile_default", + expression_intensity: float = -1.0, + backside_bias: float = -1.0, + minimal_clothing_ratio: float = -1.0, + standard_pose_ratio: float = -1.0, + trigger_policy: str = "profile_default", +) -> str: + profile = profile if profile in GENERATION_PROFILE_PRESETS else "balanced" + config = dict(GENERATION_PROFILE_PRESETS[profile]) + if clothing_override in ("full", "minimal"): + config["clothing"] = clothing_override + if poses_override in ("standard", "evocative"): + config["poses"] = poses_override + if float(expression_intensity) >= 0: + config["expression_intensity"] = _clamped_float(expression_intensity, config["expression_intensity"]) + if float(backside_bias) >= 0: + config["backside_bias"] = _clamped_float(backside_bias, config["backside_bias"]) + if float(minimal_clothing_ratio) >= 0: + config["minimal_clothing_ratio"] = _clamped_float(minimal_clothing_ratio, config["minimal_clothing_ratio"]) + if float(standard_pose_ratio) >= 0: + config["standard_pose_ratio"] = _clamped_float(standard_pose_ratio, config["standard_pose_ratio"]) + if trigger_policy == "prepend_trigger": + config["prepend_trigger_to_prompt"] = True + elif trigger_policy == "do_not_prepend": + config["prepend_trigger_to_prompt"] = False + config["profile"] = profile + return json.dumps(config, ensure_ascii=True, sort_keys=True) + + +def _parse_generation_profile(profile_config: str | dict[str, Any] | None) -> dict[str, Any]: + if not profile_config: + return dict(GENERATION_PROFILE_PRESETS["balanced"]) + if isinstance(profile_config, dict): + raw = profile_config + else: + try: + raw = json.loads(str(profile_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid generation_profile JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("generation_profile must be a JSON object") + profile = str(raw.get("profile") or "balanced") + parsed = dict(GENERATION_PROFILE_PRESETS.get(profile, GENERATION_PROFILE_PRESETS["balanced"])) + parsed.update(raw) + parsed["clothing"] = parsed["clothing"] if parsed.get("clothing") in ("full", "minimal") else "full" + parsed["poses"] = parsed["poses"] if parsed.get("poses") in ("standard", "evocative") else "standard" + parsed["expression_intensity"] = _clamped_float(parsed.get("expression_intensity"), 0.5) + parsed["backside_bias"] = _clamped_float(parsed.get("backside_bias"), 0.0) + parsed["minimal_clothing_ratio"] = _clamped_float(parsed.get("minimal_clothing_ratio"), -1.0, -1.0, 1.0) + parsed["standard_pose_ratio"] = _clamped_float(parsed.get("standard_pose_ratio"), -1.0, -1.0, 1.0) + parsed["trigger"] = str(parsed.get("trigger") or "sxcpinup_coloredpencil") + parsed["prepend_trigger_to_prompt"] = bool(parsed.get("prepend_trigger_to_prompt")) + return parsed + + +def build_filter_config_json( + ethnicity: str = "any", + figure: str = "curvy", + no_plus_women: bool = False, + no_black: bool = False, +) -> str: + return json.dumps( + { + "ethnicity": ethnicity if ethnicity in ("any", "asian", "white_asian") else "any", + "figure": figure if figure in ("curvy", "balanced", "bombshell") else "curvy", + "no_plus_women": bool(no_plus_women), + "no_black": bool(no_black), + }, + ensure_ascii=True, + sort_keys=True, + ) + + +def _parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str, Any]: + defaults = {"ethnicity": "any", "figure": "curvy", "no_plus_women": False, "no_black": False} + if not filter_config: + return defaults + if isinstance(filter_config, dict): + raw = filter_config + else: + try: + raw = json.loads(str(filter_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid filter_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("filter_config must be a JSON object") + parsed = {**defaults, **raw} + parsed["ethnicity"] = parsed["ethnicity"] if parsed.get("ethnicity") in ("any", "asian", "white_asian") else "any" + parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell") else "curvy" + parsed["no_plus_women"] = bool(parsed.get("no_plus_women")) + parsed["no_black"] = bool(parsed.get("no_black")) + return parsed + + def _ratio_or_none(value: float) -> float | None: try: ratio = float(value) @@ -1162,6 +1425,238 @@ def _body_phrase(body: Any, figure_note: Any = "") -> str: return f"{body} figure with {figure_note}" +def _safe_profile_name(profile_name: str) -> str: + profile_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(profile_name or "").strip()).strip("_") + return profile_name[:64] or "profile" + + +def _profile_path(profile_name: str) -> Path: + return PROFILE_DIR / f"{_safe_profile_name(profile_name)}.json" + + +def character_profile_choices() -> list[str]: + if not PROFILE_DIR.exists(): + return ["manual"] + names = sorted(path.stem for path in PROFILE_DIR.glob("*.json") if path.is_file()) + return ["manual"] + names + + +def _load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[str, Any]: + if not value: + return {} + if isinstance(value, dict): + return value + try: + raw = json.loads(str(value)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid {label} JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError(f"{label} must be a JSON object") + return raw + + +def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]: + row = _load_json_object(metadata_json, "metadata_json") + if isinstance(row.get("softcore_row"), dict): + return row["softcore_row"] + return row + + +def _character_profile_descriptor(profile: dict[str, Any]) -> str: + subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip() + age = str(profile.get("age") or "").strip() + age = age.removesuffix(" adults").removesuffix(" adult").strip() + subject_phrase = f"{age} adult {subject}".strip() if age else f"adult {subject}" + pieces = [ + subject_phrase, + profile.get("body_phrase") or _body_phrase(profile.get("body"), profile.get("figure")), + profile.get("skin"), + profile.get("hair"), + profile.get("eyes"), + ] + return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip()) + + +def _normalize_character_profile(profile: dict[str, Any], profile_name: str = "") -> dict[str, Any]: + subject_type = str(profile.get("subject_type") or profile.get("primary_subject") or profile.get("subject") or "").strip() + if subject_type not in ("woman", "man"): + subject_type = "woman" + body = str(profile.get("body") or profile.get("body_type") or "").strip() + figure = str(profile.get("figure") or "").strip() + body_phrase = str(profile.get("body_phrase") or "").strip() or _body_phrase(body, figure) + normalized = { + "profile_type": "character", + "profile_name": _safe_profile_name(profile_name or str(profile.get("profile_name") or "")), + "subject_type": subject_type, + "subject": subject_type, + "subject_phrase": subject_type, + "age": str(profile.get("age") or profile.get("age_band") or "").strip(), + "body": body, + "body_phrase": body_phrase, + "skin": str(profile.get("skin") or "").strip(), + "hair": str(profile.get("hair") or "").strip(), + "eyes": str(profile.get("eyes") or "").strip(), + "figure": figure, + } + normalized["descriptor"] = _character_profile_descriptor(normalized) + return normalized + + +def build_character_profile_json( + profile_name: str = "", + source: str = "metadata_json", + metadata_json: str | dict[str, Any] | None = "", + subject_type: str = "woman", + age: str = "", + body: str = "", + body_phrase: str = "", + skin: str = "", + hair: str = "", + eyes: str = "", + figure: str = "", + save_now: bool = False, +) -> dict[str, str]: + if source == "metadata_json": + row = _row_from_profile_metadata(metadata_json) + raw_profile = { + "profile_name": profile_name, + "subject_type": row.get("subject_type") or row.get("primary_subject") or subject_type, + "age": row.get("age") or row.get("age_band") or age, + "body": row.get("body") or row.get("body_type") or body, + "body_phrase": row.get("body_phrase") or body_phrase, + "skin": row.get("skin") or skin, + "hair": row.get("hair") or hair, + "eyes": row.get("eyes") or eyes, + "figure": row.get("figure") or figure, + } + else: + raw_profile = { + "profile_name": profile_name, + "subject_type": subject_type, + "age": age, + "body": body, + "body_phrase": body_phrase, + "skin": skin, + "hair": hair, + "eyes": eyes, + "figure": figure, + } + profile = _normalize_character_profile(raw_profile, profile_name) + saved_path = "" + status = "not_saved" + if save_now: + PROFILE_DIR.mkdir(parents=True, exist_ok=True) + path = _profile_path(profile["profile_name"]) + path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") + saved_path = str(path) + status = "saved" + return { + "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), + "profile_name": profile["profile_name"], + "descriptor": profile["descriptor"], + "saved_path": saved_path, + "status": status, + } + + +def _empty_profile_result(status: str = "empty") -> dict[str, str]: + return { + "profile_json": "", + "profile_name": "", + "descriptor": "", + "saved_path": "", + "status": status, + } + + +def load_character_profile_json( + profile_name: str = "", + fallback_profile_json: str | dict[str, Any] | None = "", + enabled: bool = True, + delete_now: bool = False, + rename_now: bool = False, + rename_to: str = "", +) -> dict[str, str]: + if not enabled: + return _empty_profile_result("disabled") + if delete_now and rename_now: + return _empty_profile_result("choose_delete_or_rename") + + raw_profile = _load_json_object(fallback_profile_json, "fallback_profile_json") + saved_path = "" + if profile_name and profile_name != "manual": + path = _profile_path(profile_name) + if delete_now: + if path.exists(): + path.unlink() + return _empty_profile_result(f"deleted:{path.stem}") + return _empty_profile_result(f"delete_missing:{_safe_profile_name(profile_name)}") + if rename_now: + new_name = _safe_profile_name(rename_to) + if not rename_to.strip(): + return _empty_profile_result("rename_missing_name") + if not path.exists(): + return _empty_profile_result(f"rename_missing:{_safe_profile_name(profile_name)}") + target = _profile_path(new_name) + if target.exists() and target != path: + return _empty_profile_result(f"rename_target_exists:{target.stem}") + raw_profile = _load_json_object(path.read_text(encoding="utf-8"), "character_profile") + profile = _normalize_character_profile(raw_profile, new_name) + target.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if target != path: + path.unlink() + return { + "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), + "profile_name": profile["profile_name"], + "descriptor": profile["descriptor"], + "saved_path": str(target), + "status": f"renamed:{path.stem}->{target.stem}", + } + if path.exists(): + raw_profile = _load_json_object(path.read_text(encoding="utf-8"), "character_profile") + saved_path = str(path) + if not raw_profile: + return _empty_profile_result("empty") + profile = _normalize_character_profile(raw_profile, profile_name or raw_profile.get("profile_name", "")) + return { + "profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True), + "profile_name": profile["profile_name"], + "descriptor": profile["descriptor"], + "saved_path": saved_path, + "status": "loaded" if saved_path else "fallback", + } + + +def _parse_character_profile(character_profile: str | dict[str, Any] | None) -> dict[str, Any]: + raw = _load_json_object(character_profile, "character_profile") + if not raw: + return {} + if raw.get("profile_type") == "character" or any(key in raw for key in ("age", "age_band", "skin", "hair", "eyes")): + return _normalize_character_profile(raw, str(raw.get("profile_name") or "")) + return {} + + +def _apply_character_profile_to_context( + context: dict[str, Any], + character_profile: str | dict[str, Any] | None, +) -> tuple[dict[str, Any], dict[str, Any], str]: + profile = _parse_character_profile(character_profile) + if not profile: + return context, {}, "none" + if context.get("subject_type") not in ("woman", "man"): + return context, profile, "skipped_non_single_subject" + if profile["subject_type"] != context.get("subject_type"): + return context, profile, "skipped_subject_mismatch" + updated = dict(context) + for key in ("subject_type", "subject", "subject_phrase", "age", "body", "body_phrase", "skin", "hair", "eyes", "figure"): + value = profile.get(key) + if value: + updated[key] = value + updated["subject"] = profile["subject_type"] + updated["subject_phrase"] = profile["subject_type"] + return updated, profile, "applied" + + def _composition_prompt(composition: str) -> str: composition = str(composition or "").strip() if not composition: @@ -1760,6 +2255,7 @@ def _build_custom_row( seed: int, seed_config: dict[str, int], expression_intensity: float, + character_profile: str | dict[str, Any] | None = None, ) -> dict[str, Any]: categories = load_category_library() category_rng = _axis_rng(seed_config, "category", seed, row_number) @@ -1797,6 +2293,7 @@ def _build_custom_row( item_text, item_name, item_axis_values = _compose_item(content_rng, category, subcategory, item, women_count, men_count) subject_type = str(_merged_field(category, subcategory, item, "subject_type", "single_any")) context = _subject_context(person_rng, subject_type, ethnicity, figure, no_plus_women, no_black, women_count, men_count) + context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) subject_type = context["subject_type"] role_graph = _role_graph(role_rng, subcategory, context, item_axis_values) @@ -1914,6 +2411,8 @@ def _build_custom_row( "men_count": context.get("men_count", ""), "person_count": context.get("person_count", ""), "cast_count_adjustment": count_adjustment if subject_type == "configured_cast" else {}, + "character_profile": applied_profile, + "character_profile_status": profile_status, "source": "json_category", } ) @@ -1946,6 +2445,7 @@ def build_prompt( men_count: int = 1, camera_config: str | dict[str, Any] | None = None, expression_intensity: float = 0.5, + character_profile: str | dict[str, Any] | None = None, ) -> dict[str, Any]: apply_pool_extensions() row_number = max(1, int(row_number)) @@ -2009,6 +2509,7 @@ def build_prompt( seed, parsed_seed_config, expression_intensity, + character_profile, ) if extra_positive.strip(): @@ -2022,6 +2523,52 @@ def build_prompt( return row +def build_prompt_from_configs( + row_number: int, + start_index: int, + seed: int, + category_config: str | dict[str, Any] | None = "", + cast_config: str | dict[str, Any] | None = "", + generation_profile: str | dict[str, Any] | None = "", + filter_config: str | dict[str, Any] | None = "", + seed_config: str | dict[str, Any] | None = "", + camera_config: str | dict[str, Any] | None = "", + character_profile: str | dict[str, Any] | None = "", + extra_positive: str = "", + extra_negative: str = "", +) -> dict[str, Any]: + category, subcategory = _parse_category_config(category_config) + cast = _parse_cast_config(cast_config) + profile = _parse_generation_profile(generation_profile) + filters = _parse_filter_config(filter_config) + return build_prompt( + category=category, + subcategory=subcategory, + row_number=row_number, + start_index=start_index, + seed=seed, + clothing=profile["clothing"], + ethnicity=filters["ethnicity"], + poses=profile["poses"], + expression_intensity=profile["expression_intensity"], + backside_bias=profile["backside_bias"], + figure=filters["figure"], + no_plus_women=filters["no_plus_women"], + no_black=filters["no_black"], + women_count=int(cast["women_count"]), + men_count=int(cast["men_count"]), + minimal_clothing_ratio=profile["minimal_clothing_ratio"], + standard_pose_ratio=profile["standard_pose_ratio"], + trigger=profile["trigger"], + prepend_trigger_to_prompt=profile["prepend_trigger_to_prompt"], + extra_positive=extra_positive or "", + extra_negative=extra_negative or "", + seed_config=seed_config or "", + camera_config=camera_config or "", + character_profile=character_profile or "", + ) + + 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", @@ -2264,6 +2811,7 @@ def build_insta_of_pair( seed_config: str | dict[str, Any] | None = None, options_json: str | dict[str, Any] | None = None, camera_config: str | dict[str, Any] | None = None, + character_profile: str | dict[str, Any] | None = "", extra_positive: str = "", extra_negative: str = "", ) -> dict[str, Any]: @@ -2295,6 +2843,7 @@ def build_insta_of_pair( women_count=1, men_count=0, expression_intensity=options["softcore_expression_intensity"], + character_profile=character_profile or "", ) hard_row = build_prompt( category="Hardcore sexual poses", diff --git a/web/profile_buttons.js b/web/profile_buttons.js new file mode 100644 index 0000000..f5ece80 --- /dev/null +++ b/web/profile_buttons.js @@ -0,0 +1,112 @@ +import { app } from "../../scripts/app.js"; + +const EXTENSION = "ethanfel.prompt_builder.profile_buttons"; + +function widget(node, name) { + return node.widgets?.find((w) => w.name === name); +} + +function hideWidget(w) { + if (!w) return; + if (w.origType === undefined) w.origType = w.type; + w.type = "hidden"; + w.hidden = true; + w.computeSize = () => [0, -4]; +} + +function resizeNode(node) { + const size = node.computeSize?.(); + if (size) node.setSize?.(size); + app.graph?.setDirtyCanvas(true, true); +} + +async function queueOnce(node, triggerName) { + const trigger = widget(node, triggerName); + if (!trigger) { + alert(`Missing trigger widget: ${triggerName}`); + return; + } + + trigger.value = true; + node.setDirtyCanvas?.(true, true); + try { + try { + await app.queuePrompt(0, 1); + } catch (_err) { + await app.queuePrompt(0); + } + } catch (err) { + console.error(`[${EXTENSION}] queue failed`, err); + alert(`Queue failed: ${err}`); + } finally { + trigger.value = false; + node.setDirtyCanvas?.(true, true); + } +} + +function setupSaveNode(node) { + hideWidget(widget(node, "save_now")); + if (!node._sxcpSaveButton) { + node._sxcpSaveButton = node.addWidget("button", "Save Profile Now", null, () => queueOnce(node, "save_now")); + } + resizeNode(node); +} + +function setupLoadNode(node) { + hideWidget(widget(node, "delete_now")); + hideWidget(widget(node, "rename_now")); + if (!node._sxcpDeleteButton) { + node._sxcpDeleteButton = node.addWidget("button", "Delete Selected Profile", null, () => { + const profile = widget(node, "profile_name")?.value || ""; + if (!profile || profile === "manual") { + alert("Select a saved profile before deleting."); + return; + } + if (!confirm(`Delete saved profile "${profile}"?`)) return; + queueOnce(node, "delete_now"); + }); + } + if (!node._sxcpRenameButton) { + node._sxcpRenameButton = node.addWidget("button", "Rename Selected Profile", null, () => { + const profile = widget(node, "profile_name")?.value || ""; + const target = widget(node, "rename_to")?.value || ""; + if (!profile || profile === "manual") { + alert("Select a saved profile before renaming."); + return; + } + if (!target.trim()) { + alert("Fill rename_to before renaming."); + return; + } + if (!confirm(`Rename saved profile "${profile}" to "${target}"?`)) return; + queueOnce(node, "rename_now"); + }); + } + resizeNode(node); +} + +app.registerExtension({ + name: EXTENSION, + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "SxCPCharacterProfileSave" && nodeData.name !== "SxCPCharacterProfileLoad") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + if (nodeData.name === "SxCPCharacterProfileSave") setupSaveNode(this); + if (nodeData.name === "SxCPCharacterProfileLoad") setupLoadNode(this); + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = onConfigure?.apply(this, arguments); + queueMicrotask(() => { + if (nodeData.name === "SxCPCharacterProfileSave") setupSaveNode(this); + if (nodeData.name === "SxCPCharacterProfileLoad") setupLoadNode(this); + }); + return result; + }; + }, +});