Add split workflow nodes and profile controls

This commit is contained in:
2026-06-24 12:40:34 +02:00
parent 543e2feab7
commit 9c72af0585
6 changed files with 1364 additions and 0 deletions
+74
View File
@@ -8,6 +8,13 @@ The node is registered as:
- `prompt_builder / SxCP Prompt Builder` - `prompt_builder / SxCP Prompt Builder`
- `prompt_builder / SxCP Seed Control` - `prompt_builder / SxCP Seed Control`
- `prompt_builder / SxCP Camera 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 Caption Naturalizer`
- `prompt_builder / SxCP Krea2 Formatter` - `prompt_builder / SxCP Krea2 Formatter`
- `prompt_builder / SxCP Insta/OF Options` - `prompt_builder / SxCP Insta/OF Options`
@@ -22,6 +29,71 @@ It outputs:
- `category` - `category`
- `subcategory` - `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/<profile_name>.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 `SxCP Seed Control` outputs `seed_config`, which can be connected to the prompt
builder's optional `seed_config` input. builder's optional `seed_config` input.
@@ -190,6 +262,8 @@ or reload ComfyUI after changing JSON so dropdown choices are rebuilt.
Included JSON categories: Included JSON categories:
- `Casual clothes` - `Casual clothes`
- `Men casual clothes`
- `Couple casual clothes`
- `Provocative erotic clothes` - `Provocative erotic clothes`
- `Hardcore sexual poses` - `Hardcore sexual poses`
+334
View File
@@ -5,9 +5,15 @@ import json
try: try:
from .prompt_builder import ( from .prompt_builder import (
build_camera_config_json, 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_options_json,
build_insta_of_pair, build_insta_of_pair,
build_prompt, build_prompt,
build_prompt_from_configs,
build_seed_config_json, build_seed_config_json,
camera_angle_choices, camera_angle_choices,
camera_distance_choices, camera_distance_choices,
@@ -17,7 +23,12 @@ try:
camera_phone_choices, camera_phone_choices,
camera_priority_choices, camera_priority_choices,
camera_shot_choices, camera_shot_choices,
cast_preset_choices,
category_preset_choices,
category_choices, category_choices,
character_profile_choices,
generation_profile_choices,
load_character_profile_json,
subcategory_choices, subcategory_choices,
) )
from .caption_naturalizer import naturalize_caption from .caption_naturalizer import naturalize_caption
@@ -25,9 +36,15 @@ try:
except ImportError: except ImportError:
from prompt_builder import ( from prompt_builder import (
build_camera_config_json, 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_options_json,
build_insta_of_pair, build_insta_of_pair,
build_prompt, build_prompt,
build_prompt_from_configs,
build_seed_config_json, build_seed_config_json,
camera_angle_choices, camera_angle_choices,
camera_distance_choices, camera_distance_choices,
@@ -37,7 +54,12 @@ except ImportError:
camera_phone_choices, camera_phone_choices,
camera_priority_choices, camera_priority_choices,
camera_shot_choices, camera_shot_choices,
cast_preset_choices,
category_preset_choices,
category_choices, category_choices,
character_profile_choices,
generation_profile_choices,
load_character_profile_json,
subcategory_choices, subcategory_choices,
) )
from caption_naturalizer import naturalize_caption from caption_naturalizer import naturalize_caption
@@ -72,6 +94,7 @@ class SxCPPromptBuilder:
"optional": { "optional": {
"seed_config": ("STRING", {"default": "", "multiline": True}), "seed_config": ("STRING", {"default": "", "multiline": True}),
"camera_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}),
"character_profile": ("STRING", {"default": "", "multiline": True}),
"extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}),
"extra_negative": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}),
}, },
@@ -105,6 +128,7 @@ class SxCPPromptBuilder:
prepend_trigger_to_prompt, prepend_trigger_to_prompt,
seed_config="", seed_config="",
camera_config="", camera_config="",
character_profile="",
extra_positive="", extra_positive="",
extra_negative="", extra_negative="",
): ):
@@ -132,6 +156,7 @@ class SxCPPromptBuilder:
extra_negative=extra_negative or "", extra_negative=extra_negative or "",
seed_config=seed_config or "", seed_config=seed_config or "",
camera_config=camera_config or "", camera_config=camera_config or "",
character_profile=character_profile or "",
) )
return ( return (
row["prompt"], 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: class SxCPCaptionNaturalizer:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -428,6 +741,7 @@ class SxCPInstaOFPromptPair:
"seed_config": ("STRING", {"default": "", "multiline": True}), "seed_config": ("STRING", {"default": "", "multiline": True}),
"options_json": ("STRING", {"default": "", "multiline": True}), "options_json": ("STRING", {"default": "", "multiline": True}),
"camera_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}),
"character_profile": ("STRING", {"default": "", "multiline": True}),
"extra_positive": ("STRING", {"default": "", "multiline": True}), "extra_positive": ("STRING", {"default": "", "multiline": True}),
"extra_negative": ("STRING", {"default": "", "multiline": True}), "extra_negative": ("STRING", {"default": "", "multiline": True}),
}, },
@@ -461,6 +775,7 @@ class SxCPInstaOFPromptPair:
seed_config="", seed_config="",
options_json="", options_json="",
camera_config="", camera_config="",
character_profile="",
extra_positive="", extra_positive="",
extra_negative="", extra_negative="",
): ):
@@ -477,6 +792,7 @@ class SxCPInstaOFPromptPair:
seed_config=seed_config or "", seed_config=seed_config or "",
options_json=options_json or "", options_json=options_json or "",
camera_config=camera_config or "", camera_config=camera_config or "",
character_profile=character_profile or "",
extra_positive=extra_positive or "", extra_positive=extra_positive or "",
extra_negative=extra_negative or "", extra_negative=extra_negative or "",
) )
@@ -496,6 +812,13 @@ NODE_CLASS_MAPPINGS = {
"SxCPPromptBuilder": SxCPPromptBuilder, "SxCPPromptBuilder": SxCPPromptBuilder,
"SxCPSeedControl": SxCPSeedControl, "SxCPSeedControl": SxCPSeedControl,
"SxCPCameraControl": SxCPCameraControl, "SxCPCameraControl": SxCPCameraControl,
"SxCPCategoryPreset": SxCPCategoryPreset,
"SxCPCastControl": SxCPCastControl,
"SxCPGenerationProfile": SxCPGenerationProfile,
"SxCPAdvancedFilters": SxCPAdvancedFilters,
"SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs,
"SxCPCharacterProfileSave": SxCPCharacterProfileSave,
"SxCPCharacterProfileLoad": SxCPCharacterProfileLoad,
"SxCPCaptionNaturalizer": SxCPCaptionNaturalizer, "SxCPCaptionNaturalizer": SxCPCaptionNaturalizer,
"SxCPKrea2Formatter": SxCPKrea2Formatter, "SxCPKrea2Formatter": SxCPKrea2Formatter,
"SxCPInstaOFOptions": SxCPInstaOFOptions, "SxCPInstaOFOptions": SxCPInstaOFOptions,
@@ -506,8 +829,19 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPPromptBuilder": "SxCP Prompt Builder", "SxCPPromptBuilder": "SxCP Prompt Builder",
"SxCPSeedControl": "SxCP Seed Control", "SxCPSeedControl": "SxCP Seed Control",
"SxCPCameraControl": "SxCP Camera 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", "SxCPCaptionNaturalizer": "SxCP Caption Naturalizer",
"SxCPKrea2Formatter": "SxCP Krea2 Formatter", "SxCPKrea2Formatter": "SxCP Krea2 Formatter",
"SxCPInstaOFOptions": "SxCP Insta/OF Options", "SxCPInstaOFOptions": "SxCP Insta/OF Options",
"SxCPInstaOFPromptPair": "SxCP Insta/OF Prompt Pair", "SxCPInstaOFPromptPair": "SxCP Insta/OF Prompt Pair",
} }
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+293
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+549
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import random import random
import re
from pathlib import Path from pathlib import Path
from string import Formatter from string import Formatter
from typing import Any from typing import Any
@@ -14,6 +15,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
ROOT_DIR = Path(__file__).resolve().parent ROOT_DIR = Path(__file__).resolve().parent
CATEGORY_DIR = ROOT_DIR / "categories" CATEGORY_DIR = ROOT_DIR / "categories"
PROFILE_DIR = ROOT_DIR / "profiles"
BUILTIN_CATEGORIES = [ BUILTIN_CATEGORIES = [
"auto_weighted", "auto_weighted",
@@ -688,6 +690,267 @@ def subcategory_choices() -> list[str]:
return choices 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: def _ratio_or_none(value: float) -> float | None:
try: try:
ratio = float(value) ratio = float(value)
@@ -1162,6 +1425,238 @@ def _body_phrase(body: Any, figure_note: Any = "") -> str:
return f"{body} figure with {figure_note}" 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: def _composition_prompt(composition: str) -> str:
composition = str(composition or "").strip() composition = str(composition or "").strip()
if not composition: if not composition:
@@ -1760,6 +2255,7 @@ def _build_custom_row(
seed: int, seed: int,
seed_config: dict[str, int], seed_config: dict[str, int],
expression_intensity: float, expression_intensity: float,
character_profile: str | dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
categories = load_category_library() categories = load_category_library()
category_rng = _axis_rng(seed_config, "category", seed, row_number) 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) 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")) 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 = _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"] subject_type = context["subject_type"]
role_graph = _role_graph(role_rng, subcategory, context, item_axis_values) 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", ""), "men_count": context.get("men_count", ""),
"person_count": context.get("person_count", ""), "person_count": context.get("person_count", ""),
"cast_count_adjustment": count_adjustment if subject_type == "configured_cast" else {}, "cast_count_adjustment": count_adjustment if subject_type == "configured_cast" else {},
"character_profile": applied_profile,
"character_profile_status": profile_status,
"source": "json_category", "source": "json_category",
} }
) )
@@ -1946,6 +2445,7 @@ def build_prompt(
men_count: int = 1, men_count: int = 1,
camera_config: str | dict[str, Any] | None = None, camera_config: str | dict[str, Any] | None = None,
expression_intensity: float = 0.5, expression_intensity: float = 0.5,
character_profile: str | dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
apply_pool_extensions() apply_pool_extensions()
row_number = max(1, int(row_number)) row_number = max(1, int(row_number))
@@ -2009,6 +2509,7 @@ def build_prompt(
seed, seed,
parsed_seed_config, parsed_seed_config,
expression_intensity, expression_intensity,
character_profile,
) )
if extra_positive.strip(): if extra_positive.strip():
@@ -2022,6 +2523,52 @@ def build_prompt(
return row 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 = { INSTA_OF_SOFT_LEVELS = {
"social_tease": "Instagram-style thirst-trap post, suggestive but non-explicit, polished social feed energy", "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", "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, seed_config: str | dict[str, Any] | None = None,
options_json: str | dict[str, Any] | None = None, options_json: str | dict[str, Any] | None = None,
camera_config: 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_positive: str = "",
extra_negative: str = "", extra_negative: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -2295,6 +2843,7 @@ def build_insta_of_pair(
women_count=1, women_count=1,
men_count=0, men_count=0,
expression_intensity=options["softcore_expression_intensity"], expression_intensity=options["softcore_expression_intensity"],
character_profile=character_profile or "",
) )
hard_row = build_prompt( hard_row = build_prompt(
category="Hardcore sexual poses", category="Hardcore sexual poses",
+112
View File
@@ -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;
};
},
});