Add chainable character slot controls

This commit is contained in:
2026-06-24 15:10:24 +02:00
parent cb35e1881f
commit a7743cfd4b
6 changed files with 671 additions and 29 deletions
+35 -1
View File
@@ -14,6 +14,7 @@ The node is registered as:
- `prompt_builder / SxCP Generation Profile` - `prompt_builder / SxCP Generation Profile`
- `prompt_builder / SxCP Advanced Filters` - `prompt_builder / SxCP Advanced Filters`
- `prompt_builder / SxCP Prompt Builder From Configs` - `prompt_builder / SxCP Prompt Builder From Configs`
- `prompt_builder / SxCP Character Slot`
- `prompt_builder / SxCP Character Profile Save` - `prompt_builder / SxCP Character Profile Save`
- `prompt_builder / SxCP Character Profile Load` - `prompt_builder / SxCP Character Profile Load`
- `prompt_builder / SxCP Caption Naturalizer` - `prompt_builder / SxCP Caption Naturalizer`
@@ -51,7 +52,8 @@ node. For cleaner workflows, use the split nodes:
The practical compact workflow is: The practical compact workflow is:
`Category Preset` + `Cast Control` + `Generation Profile` + optional `Category Preset` + `Cast Control` + `Generation Profile` + optional
`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control`, and `Character Profile` `Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control`,
`Character Slot`, and `Character Profile`
into `Prompt Builder From Configs`. into `Prompt Builder From Configs`.
An importable default workflow is included at An importable default workflow is included at
@@ -68,6 +70,29 @@ as one long chain:
## Character Profiles ## Character Profiles
`SxCP Character Slot` is the scalable per-participant control node. Each slot
defines one woman or man with optional overrides for age, ethnicity, figure,
body/body phrase, skin, hair, and eyes. Leave any field on `random` or blank to
let the generator fill that part from the normal pools; set exact values only
where you want control.
Slots are chainable through the `character_cast` input/output. In automatic
label mode, the slot closest to the final generator becomes `A` for its gender,
the next upstream slot becomes `B`, then `C`, and so on. Example:
`Woman slot 3 -> Woman slot 2 -> Woman slot 1 -> Insta/OF Prompt Pair`
In that chain, `Woman slot 1` resolves as `Woman A`, `Woman slot 2` resolves as
`Woman B`, and `Woman slot 3` resolves as `Woman C`. Men resolve separately the
same way, so the closest man slot becomes `Man A`.
Connect the final `character_cast` output to `SxCP Prompt Builder`,
`SxCP Prompt Builder From Configs`, or `SxCP Insta/OF Prompt Pair`. It applies
to JSON/custom single woman/man rows, JSON/custom configured-cast rows such as
`Hardcore sexual poses`, and Insta/OF named casts. The older profile save/load
nodes remain useful for one reusable primary character, but slots are better
when you need different settings for each participant.
`SxCP Character Profile Save` extracts a reusable woman/man profile from `SxCP Character Profile Save` extracts a reusable woman/man profile from
`metadata_json` or from manual fields. The profile stores age, body/body phrase, `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 skin, hair, eyes, figure, and subject type. It only writes a file when
@@ -173,6 +198,9 @@ Important behavior:
`minimal` omits most style text. `minimal` omits most style text.
- For Insta/OF paired metadata, the node returns both `krea_softcore_prompt` and - For Insta/OF paired metadata, the node returns both `krea_softcore_prompt` and
`krea_hardcore_prompt`, with separate softcore and hardcore negatives. `krea_hardcore_prompt`, with separate softcore and hardcore negatives.
- Insta/OF cast metadata is rewritten as direct named-character prose such as
`Woman A is ...` and `Man A is ...`, so Krea2 does not have to interpret a
`Cast descriptors:` label.
It outputs: It outputs:
@@ -197,6 +225,12 @@ and location. The generated positive prompts are still standalone: each output
lists the relevant cast descriptors directly and does not depend on the image lists the relevant cast descriptors directly and does not depend on the image
model carrying context from another prompt. model carrying context from another prompt.
For per-character control, chain `SxCP Character Slot` nodes into the pair
node's `character_cast` input. The nearest woman slot controls the shared
primary creator (`Woman A`) in both softcore and hardcore outputs; additional
woman/man slots fill partner descriptors before random fallback descriptors are
used.
It outputs: It outputs:
- `softcore_prompt` - `softcore_prompt`
+88
View File
@@ -7,6 +7,7 @@ try:
build_camera_config_json, build_camera_config_json,
build_cast_config_json, build_cast_config_json,
build_category_config_json, build_category_config_json,
build_character_slot_json,
build_character_profile_json, build_character_profile_json,
build_filter_config_json, build_filter_config_json,
build_generation_profile_json, build_generation_profile_json,
@@ -28,6 +29,11 @@ try:
cast_preset_choices, cast_preset_choices,
category_preset_choices, category_preset_choices,
category_choices, category_choices,
character_age_choices,
character_body_choices,
character_ethnicity_choices,
character_figure_choices,
character_label_choices,
character_profile_choices, character_profile_choices,
ethnicity_choices, ethnicity_choices,
generation_profile_choices, generation_profile_choices,
@@ -41,6 +47,7 @@ except ImportError:
build_camera_config_json, build_camera_config_json,
build_cast_config_json, build_cast_config_json,
build_category_config_json, build_category_config_json,
build_character_slot_json,
build_character_profile_json, build_character_profile_json,
build_filter_config_json, build_filter_config_json,
build_generation_profile_json, build_generation_profile_json,
@@ -62,6 +69,11 @@ except ImportError:
cast_preset_choices, cast_preset_choices,
category_preset_choices, category_preset_choices,
category_choices, category_choices,
character_age_choices,
character_body_choices,
character_ethnicity_choices,
character_figure_choices,
character_label_choices,
character_profile_choices, character_profile_choices,
ethnicity_choices, ethnicity_choices,
generation_profile_choices, generation_profile_choices,
@@ -99,6 +111,7 @@ class SxCPPromptBuilder:
"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}), "character_profile": ("STRING", {"default": "", "multiline": True}),
"character_cast": ("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}),
}, },
@@ -131,6 +144,7 @@ class SxCPPromptBuilder:
seed_config="", seed_config="",
camera_config="", camera_config="",
character_profile="", character_profile="",
character_cast="",
extra_positive="", extra_positive="",
extra_negative="", extra_negative="",
no_plus_women=False, no_plus_women=False,
@@ -161,6 +175,7 @@ class SxCPPromptBuilder:
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 "", character_profile=character_profile or "",
character_cast=character_cast or "",
) )
return ( return (
row["prompt"], row["prompt"],
@@ -474,6 +489,7 @@ class SxCPPromptBuilderFromConfigs:
"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}), "character_profile": ("STRING", {"default": "", "multiline": True}),
"character_cast": ("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}),
}, },
@@ -496,6 +512,7 @@ class SxCPPromptBuilderFromConfigs:
seed_config="", seed_config="",
camera_config="", camera_config="",
character_profile="", character_profile="",
character_cast="",
extra_positive="", extra_positive="",
extra_negative="", extra_negative="",
): ):
@@ -510,6 +527,7 @@ class SxCPPromptBuilderFromConfigs:
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 "", character_profile=character_profile or "",
character_cast=character_cast or "",
extra_positive=extra_positive or "", extra_positive=extra_positive or "",
extra_negative=extra_negative or "", extra_negative=extra_negative or "",
) )
@@ -523,6 +541,71 @@ class SxCPPromptBuilderFromConfigs:
) )
class SxCPCharacterSlot:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"enabled": ("BOOLEAN", {"default": True}),
"subject_type": (["woman", "man"], {"default": "woman"}),
"label": (character_label_choices(), {"default": "auto_chain"}),
"age": (character_age_choices(), {"default": "random"}),
"manual_age": ("STRING", {"default": ""}),
"ethnicity": (character_ethnicity_choices(), {"default": "random"}),
"figure": (character_figure_choices(), {"default": "random"}),
"body": (character_body_choices(), {"default": "random"}),
"manual_body": ("STRING", {"default": ""}),
"body_phrase": ("STRING", {"default": ""}),
"skin": ("STRING", {"default": ""}),
"hair": ("STRING", {"default": ""}),
"eyes": ("STRING", {"default": ""}),
},
"optional": {
"character_cast": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("character_cast", "character_slot", "summary", "status")
FUNCTION = "build"
CATEGORY = "prompt_builder"
def build(
self,
enabled,
subject_type,
label,
age,
manual_age,
ethnicity,
figure,
body,
manual_body,
body_phrase,
skin,
hair,
eyes,
character_cast="",
):
result = build_character_slot_json(
subject_type=subject_type,
label=label,
age=age,
manual_age=manual_age,
ethnicity=ethnicity,
figure=figure,
body=body,
manual_body=manual_body,
body_phrase=body_phrase,
skin=skin,
hair=hair,
eyes=eyes,
enabled=enabled,
character_cast=character_cast or "",
)
return result["character_cast"], result["character_slot"], result["summary"], result["status"]
class SxCPCharacterProfileSave: class SxCPCharacterProfileSave:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -821,6 +904,7 @@ class SxCPInstaOFPromptPair:
"filter_config": ("STRING", {"default": "", "multiline": True}), "filter_config": ("STRING", {"default": "", "multiline": True}),
"camera_config": ("STRING", {"default": "", "multiline": True}), "camera_config": ("STRING", {"default": "", "multiline": True}),
"character_profile": ("STRING", {"default": "", "multiline": True}), "character_profile": ("STRING", {"default": "", "multiline": True}),
"character_cast": ("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}),
}, },
@@ -854,6 +938,7 @@ class SxCPInstaOFPromptPair:
filter_config="", filter_config="",
camera_config="", camera_config="",
character_profile="", character_profile="",
character_cast="",
extra_positive="", extra_positive="",
extra_negative="", extra_negative="",
no_plus_women=False, no_plus_women=False,
@@ -874,6 +959,7 @@ class SxCPInstaOFPromptPair:
filter_config=filter_config or "", filter_config=filter_config or "",
camera_config=camera_config or "", camera_config=camera_config or "",
character_profile=character_profile or "", character_profile=character_profile or "",
character_cast=character_cast or "",
extra_positive=extra_positive or "", extra_positive=extra_positive or "",
extra_negative=extra_negative or "", extra_negative=extra_negative or "",
) )
@@ -899,6 +985,7 @@ NODE_CLASS_MAPPINGS = {
"SxCPGenerationProfile": SxCPGenerationProfile, "SxCPGenerationProfile": SxCPGenerationProfile,
"SxCPAdvancedFilters": SxCPAdvancedFilters, "SxCPAdvancedFilters": SxCPAdvancedFilters,
"SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs, "SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs,
"SxCPCharacterSlot": SxCPCharacterSlot,
"SxCPCharacterProfileSave": SxCPCharacterProfileSave, "SxCPCharacterProfileSave": SxCPCharacterProfileSave,
"SxCPCharacterProfileLoad": SxCPCharacterProfileLoad, "SxCPCharacterProfileLoad": SxCPCharacterProfileLoad,
"SxCPCaptionNaturalizer": SxCPCaptionNaturalizer, "SxCPCaptionNaturalizer": SxCPCaptionNaturalizer,
@@ -917,6 +1004,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPGenerationProfile": "SxCP Generation Profile", "SxCPGenerationProfile": "SxCP Generation Profile",
"SxCPAdvancedFilters": "SxCP Advanced Filters", "SxCPAdvancedFilters": "SxCP Advanced Filters",
"SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs", "SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs",
"SxCPCharacterSlot": "SxCP Character Slot",
"SxCPCharacterProfileSave": "SxCP Character Profile Save", "SxCPCharacterProfileSave": "SxCP Character Profile Save",
"SxCPCharacterProfileLoad": "SxCP Character Profile Load", "SxCPCharacterProfileLoad": "SxCP Character Profile Load",
"SxCPCaptionNaturalizer": "SxCP Caption Naturalizer", "SxCPCaptionNaturalizer": "SxCP Caption Naturalizer",
+7 -2
View File
@@ -17,6 +17,8 @@ PROMPT_FIELD_LABELS = (
"Ages", "Ages",
"Body types", "Body types",
"Cast", "Cast",
"Cast descriptors",
"Characters",
"Scene", "Scene",
"Setting", "Setting",
"Pose", "Pose",
@@ -420,11 +422,14 @@ def _configured_cast_from_row(row: dict[str, Any], detail_level: str, keep_style
scene = _row_value(row, "scene_text", ("Setting", "Scene")) scene = _row_value(row, "scene_text", ("Setting", "Scene"))
expression = _row_value(row, "expression", ("Facial expressions", "Facial expression")) expression = _row_value(row, "expression", ("Facial expressions", "Facial expression"))
composition = _normalize_composition(_row_value(row, "composition", ("Composition",))) composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
cast_descriptor_text = _row_value(row, "cast_descriptor_text", ("Characters", "Cast descriptors"))
scene_kind = _row_value(row, "scene_kind") or "explicit adult sex scene" scene_kind = _row_value(row, "scene_kind") or "explicit adult sex scene"
style = _row_value(row, "style") if keep_style else "" style = _row_value(row, "style") if keep_style else ""
parts = [f"{_cap_first(subject)} {verb} shown as a consensual {scene_kind}, with all participants 21+"] parts = [f"{_cap_first(subject)} {verb} shown as a consensual {scene_kind}"]
if cast: if cast_descriptor_text:
parts.append(f"The named characters are {cast_descriptor_text}")
if cast and not cast_descriptor_text:
parts.append(f"The cast is {cast}") parts.append(f"The cast is {cast}")
if role_graph: if role_graph:
parts.append(role_graph) parts.append(role_graph)
+1 -1
View File
@@ -340,7 +340,7 @@
"cunnilingus with tongue on pussy", "cunnilingus with tongue on pussy",
"face-sitting cunnilingus", "face-sitting cunnilingus",
"sixty-nine oral sex", "sixty-nine oral sex",
"blowjob while another partner watches", {"text": "blowjob while another partner watches", "min_people": 3},
"pussy licking with thighs spread", "pussy licking with thighs spread",
"cock sucking with visible saliva", "cock sucking with visible saliva",
"oral sex with tongue and fingers", "oral sex with tongue and fingers",
+84 -13
View File
@@ -14,6 +14,8 @@ PROMPT_FIELD_LABELS = (
"Ages", "Ages",
"Body types", "Body types",
"Cast", "Cast",
"Cast descriptors",
"Characters",
"Scene", "Scene",
"Setting", "Setting",
"Pose", "Pose",
@@ -168,6 +170,65 @@ def _prompt_cast_descriptors(text: str) -> str:
return _clean(text).replace("Woman A / primary creator:", "Woman A:") return _clean(text).replace("Woman A / primary creator:", "Woman A:")
def _cast_entries(text: str) -> list[tuple[str, str]]:
text = _prompt_cast_descriptors(text)
entries: list[tuple[str, str]] = []
for part in text.split(";"):
part = _clean(part)
match = re.match(r"^((?:Woman|Man) [A-Z]):\s*(.+)$", part)
if match:
entries.append((match.group(1), _clean(match.group(2))))
return entries
def _label_join(labels: list[str]) -> str:
labels = [_clean(label) for label in labels if _clean(label)]
if not labels:
return "the named adults"
if len(labels) == 1:
return labels[0]
if len(labels) == 2:
return f"{labels[0]} and {labels[1]}"
return f"{', '.join(labels[:-1])}, and {labels[-1]}"
def _cast_prose(text: str, central_label: str = "Woman A") -> tuple[str, list[str]]:
entries = _cast_entries(text)
if not entries:
return (f"{central_label} is {_clean(text)}" if _clean(text) else "", [])
labels = [label for label, _descriptor in entries]
count_phrase = "one named adult" if len(entries) == 1 else f"{len(entries)} named adults"
sentences = [f"The scene contains {count_phrase}."]
for label, descriptor in entries:
sentences.append(f"{label} is {descriptor}.")
if central_label in labels:
sentences.append(f"{central_label} is the central subject.")
return " ".join(sentences), labels
def _sanitize_scene_text_for_cast(text: Any, labels: list[str]) -> str:
text = _clean(text)
if not text:
return ""
if len(labels) < 3:
text = re.sub(r"\s*(?:while|as)\s+another partner watches\b", "", text, flags=re.IGNORECASE)
text = re.sub(r"\banother partner watches\b", "", text, flags=re.IGNORECASE)
text = re.sub(r"\bwhile blowjob\b", "during a blowjob", text, flags=re.IGNORECASE)
text = re.sub(r"\bfeaturing blowjob\b", "featuring a blowjob", text, flags=re.IGNORECASE)
text = re.sub(r"\s+,", ",", text)
text = re.sub(r"\s{2,}", " ", text).strip(" ,")
return text
def _natural_clothing_state(text: Any) -> str:
text = _clean(text)
if not text:
return ""
text = re.sub(r"^Clothing state:\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r";\s*softcore visual reference:\s*", ". Softcore visual reference: ", text, flags=re.IGNORECASE)
return text
def _clean_age(age: Any) -> str: def _clean_age(age: Any) -> str:
return _clean(age) return _clean(age)
@@ -292,10 +353,17 @@ def _normal_row_to_krea(row: dict[str, Any], detail_level: str, style_mode: str)
if subject_type == "configured_cast" or _clean(row.get("cast_summary")): if subject_type == "configured_cast" or _clean(row.get("cast_summary")):
subject = _clean(row.get("subject_phrase") or primary or "adult sexual scene") subject = _clean(row.get("subject_phrase") or primary or "adult sexual scene")
cast = _clean(row.get("cast_summary")) cast = _clean(row.get("cast_summary"))
cast_descriptor_text = (
_clean(row.get("cast_descriptor_text"))
or _prompt_field(_clean(row.get("prompt")), "Characters")
or _prompt_field(_clean(row.get("prompt")), "Cast descriptors")
)
cast_prose, _cast_labels = _cast_prose(cast_descriptor_text)
role_graph = _clean(row.get("role_graph")) role_graph = _clean(row.get("role_graph"))
parts = [ parts = [
f"A consensual explicit adult scene with {subject}, all participants 21+ and visibly adult", f"A consensual explicit adult scene with {subject}",
f"The cast includes {cast}" if cast else "", cast_prose,
f"The cast includes {cast}" if cast and not cast_prose else "",
role_graph, role_graph,
f"The sexual action is {item}" if item else "", f"The sexual action is {item}" if item else "",
f"The setting is {scene}" if scene else "", f"The setting is {scene}" if scene else "",
@@ -380,9 +448,13 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str)
if options.get("softcore_cast") == "same_as_hardcore" if options.get("softcore_cast") == "same_as_hardcore"
else f"Woman A: {descriptor}" else f"Woman A: {descriptor}"
) )
soft_cast_prose, soft_labels = _cast_prose(soft_cast_descriptor_text)
hard_cast_prose, hard_labels = _cast_prose(cast_descriptor_text)
hard_item = _sanitize_scene_text_for_cast(hard.get("item"), hard_labels)
hard_role_graph = _sanitize_scene_text_for_cast(hard.get("role_graph"), hard_labels)
same_soft_cast = options.get("softcore_cast") == "same_as_hardcore" same_soft_cast = options.get("softcore_cast") == "same_as_hardcore"
soft_cast_presence = ( soft_cast_presence = (
"Woman A and the listed partners are present together in a non-explicit teaser pose, with no sex act or genital contact" f"{_label_join(soft_labels)} are together in a non-explicit teaser pose, with no sex act or genital contact"
if same_soft_cast if same_soft_cast
else "The softcore version focuses on Woman A alone" else "The softcore version focuses on Woman A alone"
) )
@@ -396,12 +468,11 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str)
partner_pose = "" partner_pose = ""
soft_parts = [ soft_parts = [
f"Cast descriptors: {soft_cast_descriptor_text}" if same_soft_cast and soft_cast_descriptor_text else "", f"Softcore {soft_level or 'creator'} Insta/OF image",
soft_cast_descriptor_text if not same_soft_cast and soft_cast_descriptor_text else "", soft_cast_prose,
soft_cast_presence, soft_cast_presence,
f"Partner softcore styling: {partner_outfit_text}" if partner_outfit_text else "", partner_outfit_text,
f"Cast pose: {partner_pose}" if partner_pose else "", f"The cast is {partner_pose}" if partner_pose else "",
f"shown in a {soft_level or 'softcore'} Insta/OF creator image",
f"wearing {soft.get('item')}" if soft.get("item") else "", f"wearing {soft.get('item')}" if soft.get("item") else "",
f"{soft.get('pose')}" if soft.get("pose") else "", f"{soft.get('pose')}" if soft.get("pose") else "",
f"with {soft.get('expression')}" if soft.get("expression") else "", f"with {soft.get('expression')}" if soft.get("expression") else "",
@@ -411,11 +482,11 @@ def _insta_pair_to_krea(row: dict[str, Any], detail_level: str, style_mode: str)
soft_style if detail_level != "concise" else "", soft_style if detail_level != "concise" else "",
] ]
hard_parts = [ hard_parts = [
f"{hard_level or 'hardcore'} scene with Woman A visually central", f"{hard_level or 'hardcore'} scene",
f"Cast descriptors: {cast_descriptor_text}" if cast_descriptor_text else "", hard_cast_prose,
_clean(row.get("hardcore_clothing_state")), _natural_clothing_state(row.get("hardcore_clothing_state")),
_clean(hard.get("role_graph")), hard_role_graph,
f"The sexual action is {hard.get('item')}" if hard.get("item") else "", f"The explicit detail shows {hard_item}" if hard_item else "",
f"set in {hard_scene}" if hard_scene else "", f"set in {hard_scene}" if hard_scene else "",
f"with {hard.get('expression')}" if hard.get("expression") else "", f"with {hard.get('expression')}" if hard.get("expression") else "",
f"framed as {hard_composition}" if hard_composition else "", f"framed as {hard_composition}" if hard_composition else "",
+455 -11
View File
@@ -76,6 +76,69 @@ ETHNICITY_FILTER_CHOICES = [
"white_asian", "white_asian",
] ]
CHARACTER_LABEL_CHOICES = [
"auto_chain",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
]
CHARACTER_AGE_CHOICES = (
["random", "manual"]
+ [f"{age}-year-old adult" for age in range(21, 86)]
+ [
"late 20s adult",
"early 30s adult",
"mid 30s adult",
"late 30s adult",
"early 40s adult",
"mid 40s adult",
"late 40s adult",
"early 50s adult",
"mid 50s adult",
"late 50s adult",
"early 60s adult",
"mid 60s adult",
"late 60s adult",
"early 70s adult",
"mid 70s adult",
"late 70s adult",
"early 80s adult",
]
)
CHARACTER_BODY_CHOICES = [
"random",
"manual",
"slim",
"petite adult",
"toned",
"athletic",
"average",
"curvy",
"soft curvy",
"curvy athletic",
"hourglass",
"slim busty",
"busty",
"busty curvy",
"voluptuous",
"plus-size",
"heavyset",
"fat",
"stocky",
"broad",
"muscular",
]
CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
GENERIC_POSITIVE_SUFFIX = ( GENERIC_POSITIVE_SUFFIX = (
@@ -1221,6 +1284,26 @@ def ethnicity_choices() -> list[str]:
return list(ETHNICITY_FILTER_CHOICES) return list(ETHNICITY_FILTER_CHOICES)
def character_label_choices() -> list[str]:
return list(CHARACTER_LABEL_CHOICES)
def character_age_choices() -> list[str]:
return list(CHARACTER_AGE_CHOICES)
def character_body_choices() -> list[str]:
return list(CHARACTER_BODY_CHOICES)
def character_ethnicity_choices() -> list[str]:
return ["random"] + list(ETHNICITY_FILTER_CHOICES)
def character_figure_choices() -> list[str]:
return ["random", "curvy", "balanced", "bombshell"]
def camera_detail_choices() -> list[str]: def camera_detail_choices() -> list[str]:
return list(CAMERA_DETAIL_CHOICES) return list(CAMERA_DETAIL_CHOICES)
@@ -1631,6 +1714,286 @@ def _load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[st
return raw return raw
def _slot_value(value: Any) -> str:
text = str(value or "").strip()
if text.lower() in CHARACTER_RANDOM_TOKENS:
return ""
return text
def _slot_manual_or_choice(choice: str, manual_value: str) -> str:
choice = str(choice or "").strip()
manual_value = str(manual_value or "").strip()
if choice == "manual":
return manual_value or "random"
if choice.lower() in CHARACTER_RANDOM_TOKENS:
return "random"
return choice
def _normalize_slot_ethnicity(value: Any) -> str:
text = str(value or "").strip()
if text.lower() in CHARACTER_RANDOM_TOKENS:
return "random"
if text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text:
return text
return "random"
def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
subject_type = str(slot.get("subject_type") or slot.get("subject") or "").strip().lower()
if subject_type not in ("woman", "man"):
subject_type = "woman"
label = str(slot.get("label") or slot.get("label_mode") or "auto_chain").strip()
label = label.replace("Woman ", "").replace("Man ", "").strip().upper()
if label == "AUTO_CHAIN":
label = "auto_chain"
if label not in CHARACTER_LABEL_CHOICES:
label = "auto_chain"
age = _slot_manual_or_choice(str(slot.get("age") or "random"), str(slot.get("manual_age") or ""))
body = _slot_manual_or_choice(str(slot.get("body") or "random"), str(slot.get("manual_body") or ""))
figure = str(slot.get("figure") or "random").strip()
if figure not in character_figure_choices():
figure = "random"
normalized = {
"profile_type": "character_slot",
"subject_type": subject_type,
"label": label,
"age": age,
"ethnicity": _normalize_slot_ethnicity(slot.get("ethnicity")),
"figure": figure,
"body": body,
"body_phrase": _slot_value(slot.get("body_phrase")),
"skin": _slot_value(slot.get("skin")),
"hair": _slot_value(slot.get("hair")),
"eyes": _slot_value(slot.get("eyes")),
}
normalized["summary"] = _character_slot_summary(normalized)
return normalized
def _parse_character_cast(character_cast: str | dict[str, Any] | list[Any] | None) -> list[dict[str, Any]]:
if not character_cast:
return []
if isinstance(character_cast, list):
raw = character_cast
elif isinstance(character_cast, dict):
raw = character_cast
else:
try:
raw = json.loads(str(character_cast))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid character_cast JSON: {exc}") from exc
if isinstance(raw, list):
slots = raw
elif isinstance(raw, dict) and isinstance(raw.get("slots"), list):
slots = raw["slots"]
elif isinstance(raw, dict) and raw.get("profile_type") == "character_slot":
slots = [raw]
elif isinstance(raw, dict) and raw.get("subject_type") in ("woman", "man"):
slots = [raw]
else:
return []
return [_normalize_character_slot(slot) for slot in slots if isinstance(slot, dict)]
def _character_slot_summary(slot: dict[str, Any]) -> str:
subject = str(slot.get("subject_type") or "woman")
label = str(slot.get("label") or "auto_chain")
label_text = "nearest free label" if label == "auto_chain" else f"{subject.capitalize()} {label}"
parts = [
subject,
label_text,
f"age={slot.get('age', 'random')}",
f"ethnicity={slot.get('ethnicity', 'random')}",
f"figure={slot.get('figure', 'random')}",
f"body={slot.get('body', 'random')}",
]
for key in ("body_phrase", "skin", "hair", "eyes"):
value = slot.get(key)
if value:
parts.append(f"{key}={value}")
return "; ".join(parts)
def build_character_slot_json(
subject_type: str = "woman",
label: str = "auto_chain",
age: str = "random",
manual_age: str = "",
ethnicity: str = "random",
figure: str = "random",
body: str = "random",
manual_body: str = "",
body_phrase: str = "",
skin: str = "",
hair: str = "",
eyes: str = "",
enabled: bool = True,
character_cast: str | dict[str, Any] | list[Any] | None = "",
) -> dict[str, str]:
existing_slots = _parse_character_cast(character_cast)
slot = _normalize_character_slot(
{
"subject_type": subject_type,
"label": label,
"age": age,
"manual_age": manual_age,
"ethnicity": ethnicity,
"figure": figure,
"body": body,
"manual_body": manual_body,
"body_phrase": body_phrase,
"skin": skin,
"hair": hair,
"eyes": eyes,
}
)
slots = existing_slots + ([slot] if enabled else [])
cast = {
"profile_type": "character_cast",
"version": 1,
"slots": slots,
}
return {
"character_cast": json.dumps(cast, ensure_ascii=True, sort_keys=True),
"character_slot": json.dumps(slot, ensure_ascii=True, sort_keys=True) if enabled else "",
"summary": slot["summary"] if enabled else "disabled",
"status": f"{len(slots)} slot(s)",
}
def _slot_explicit_label(slot: dict[str, Any]) -> str:
label = str(slot.get("label") or "").strip().upper()
if label in CHARACTER_LABEL_CHOICES and label != "AUTO_CHAIN":
return label
return ""
def _character_slot_label_map(slots: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
label_map: dict[str, dict[str, Any]] = {}
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for subject_type, prefix in (("woman", "Woman"), ("man", "Man")):
subject_slots = [slot for slot in slots if slot.get("subject_type") == subject_type]
auto_slots = [slot for slot in subject_slots if not _slot_explicit_label(slot)]
for index, slot in enumerate(reversed(auto_slots)):
if index >= len(letters):
break
label_map[f"{prefix} {letters[index]}"] = slot
for slot in subject_slots:
explicit = _slot_explicit_label(slot)
if explicit:
label_map[f"{prefix} {explicit}"] = slot
return label_map
def _context_from_character_slot(
rng: random.Random,
slot: dict[str, Any],
subject_type: str,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
) -> dict[str, str]:
slot_ethnicity = _slot_value(slot.get("ethnicity"))
slot_figure = _slot_value(slot.get("figure"))
slot_body = _slot_value(slot.get("body"))
effective_ethnicity = slot_ethnicity or ethnicity
effective_figure = slot_figure if slot_figure in ("curvy", "balanced", "bombshell") else figure
effective_no_plus = bool(no_plus_women) and not slot_body
effective_no_black = bool(no_black) and not slot_ethnicity
context = _appearance_for_subject(
rng,
subject_type,
effective_ethnicity,
effective_figure,
effective_no_plus,
effective_no_black,
)
age = _slot_value(slot.get("age"))
body_phrase = _slot_value(slot.get("body_phrase"))
if age:
context["age"] = age
if slot_body:
context["body"] = slot_body
if subject_type == "woman":
context["body_phrase"] = _body_phrase(slot_body, context.get("figure", ""))
else:
context["body_phrase"] = f"{slot_body} figure"
if body_phrase:
context["body_phrase"] = body_phrase
for key in ("skin", "hair", "eyes"):
value = _slot_value(slot.get(key))
if value:
context[key] = value
context["subject_type"] = subject_type
context["subject"] = subject_type
context["subject_phrase"] = subject_type
return context
def _character_context_for_label(
label: str,
label_map: dict[str, dict[str, Any]],
rng: random.Random,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
) -> tuple[dict[str, str], dict[str, Any] | None]:
subject_type = "man" if label.startswith("Man ") else "woman"
slot = label_map.get(label)
if slot:
return _context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot
return _appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None
def _apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
for key in ("subject_type", "subject", "subject_phrase", "age", "body", "body_phrase", "skin", "hair", "eyes", "figure"):
value = context.get(key)
if value:
row[key] = value
if context.get("age"):
row["age_band"] = context["age"]
return row
def _cast_descriptor_entries(
seed_config: dict[str, int],
seed: int,
row_number: int,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
women_count: int,
men_count: int,
character_cast: str | dict[str, Any] | list[Any] | None = "",
primary_descriptor: str = "",
) -> tuple[list[str], list[dict[str, Any]]]:
slots = _parse_character_cast(character_cast)
label_map = _character_slot_label_map(slots)
rng = _axis_rng(seed_config, "person", seed, row_number + 997)
descriptors: list[str] = []
for index in range(max(0, women_count)):
label = f"Woman {chr(ord('A') + index)}"
if index == 0 and primary_descriptor:
descriptors.append(f"Woman A / primary creator: {primary_descriptor}")
continue
context, _slot = _character_context_for_label(label, label_map, rng, ethnicity, figure, no_plus_women, no_black)
descriptors.append(f"{label}: {_insta_of_descriptor_from_context(context)}")
for index in range(max(0, men_count)):
label = f"Man {chr(ord('A') + index)}"
context, _slot = _character_context_for_label(label, label_map, rng, ethnicity, figure, no_plus_women, no_black)
descriptors.append(f"{label}: {_insta_of_descriptor_from_context(context)}")
return descriptors, slots
def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]: def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]:
row = _load_json_object(metadata_json, "metadata_json") row = _load_json_object(metadata_json, "metadata_json")
if isinstance(row.get("softcore_row"), dict): if isinstance(row.get("softcore_row"), dict):
@@ -2432,6 +2795,7 @@ def _build_custom_row(
seed_config: dict[str, int], seed_config: dict[str, int],
expression_intensity: float, expression_intensity: float,
character_profile: str | dict[str, Any] | None = None, character_profile: str | dict[str, Any] | None = None,
character_cast: str | dict[str, Any] | list[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)
@@ -2469,9 +2833,46 @@ 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)
character_slots = _parse_character_cast(character_cast)
character_slot_map = _character_slot_label_map(character_slots)
applied_slot: dict[str, Any] = {}
slot_status = "none"
if context.get("subject_type") in ("woman", "man"):
slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A"
if slot_label in character_slot_map:
context, applied_slot = _character_context_for_label(
slot_label,
character_slot_map,
person_rng,
ethnicity,
figure,
no_plus_women,
no_black,
)
slot_status = f"applied:{slot_label}"
applied_profile, profile_status = {}, "skipped_character_slot"
else:
context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile)
else:
context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile) 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)
cast_descriptors: list[str] = []
cast_descriptor_text = ""
if subject_type == "configured_cast" and character_slots:
cast_descriptors, _descriptor_slots = _cast_descriptor_entries(
seed_config,
seed,
row_number,
ethnicity,
figure,
no_plus_women,
no_black,
women_count,
men_count,
character_slots,
)
cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors))
scene_slug, scene = _choose_pair(scene_rng, _compatible_entries(_scene_pool(category, subcategory, item, subject_type), women_count, men_count)) scene_slug, scene = _choose_pair(scene_rng, _compatible_entries(_scene_pool(category, subcategory, item, subject_type), women_count, men_count))
pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text( pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text(
@@ -2523,6 +2924,7 @@ def _build_custom_row(
"composition": composition, "composition": composition,
"composition_prompt": _composition_prompt(composition), "composition_prompt": _composition_prompt(composition),
"role_graph": role_graph, "role_graph": role_graph,
"cast_descriptors": cast_descriptor_text,
"positive_suffix": positive_suffix, "positive_suffix": positive_suffix,
"negative_prompt": negative_prompt, "negative_prompt": negative_prompt,
} }
@@ -2550,7 +2952,11 @@ def _build_custom_row(
) )
prompt = _format(template, context) prompt = _format(template, context)
if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in template:
prompt = _insert_positive_directive(prompt, f"Characters: {cast_descriptor_text}.")
caption = _format(caption_template, context) caption = _format(caption_template, context)
if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in caption_template:
caption = f"{caption.rstrip()}, {cast_descriptor_text}"
batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1)
index = start_index + row_number - 1 index = start_index + row_number - 1
row = g.row_base(index, batch, context["subject"], context["age"], context["body"], scene_slug, composition) row = g.row_base(index, batch, context["subject"], context["age"], context["body"], scene_slug, composition)
@@ -2582,6 +2988,8 @@ def _build_custom_row(
"content_seed_axis": content_axis, "content_seed_axis": content_axis,
"role_graph": role_graph, "role_graph": role_graph,
"cast_summary": context.get("cast_summary", ""), "cast_summary": context.get("cast_summary", ""),
"cast_descriptors": cast_descriptors,
"cast_descriptor_text": cast_descriptor_text,
"scene_kind": context.get("scene_kind", ""), "scene_kind": context.get("scene_kind", ""),
"women_count": context.get("women_count", ""), "women_count": context.get("women_count", ""),
"men_count": context.get("men_count", ""), "men_count": context.get("men_count", ""),
@@ -2589,6 +2997,9 @@ def _build_custom_row(
"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": applied_profile,
"character_profile_status": profile_status, "character_profile_status": profile_status,
"character_slot": applied_slot,
"character_slot_status": slot_status,
"character_cast_slots": character_slots,
"source": "json_category", "source": "json_category",
} }
) )
@@ -2622,6 +3033,7 @@ def build_prompt(
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, character_profile: str | dict[str, Any] | None = None,
character_cast: str | dict[str, Any] | list[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))
@@ -2686,6 +3098,7 @@ def build_prompt(
parsed_seed_config, parsed_seed_config,
expression_intensity, expression_intensity,
character_profile, character_profile,
character_cast,
) )
if extra_positive.strip(): if extra_positive.strip():
@@ -2710,6 +3123,7 @@ def build_prompt_from_configs(
seed_config: str | dict[str, Any] | None = "", seed_config: str | dict[str, Any] | None = "",
camera_config: str | dict[str, Any] | None = "", camera_config: str | dict[str, Any] | None = "",
character_profile: str | dict[str, Any] | None = "", character_profile: str | dict[str, Any] | None = "",
character_cast: str | dict[str, Any] | list[Any] | None = "",
extra_positive: str = "", extra_positive: str = "",
extra_negative: str = "", extra_negative: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -2742,6 +3156,7 @@ def build_prompt_from_configs(
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 "", character_profile=character_profile or "",
character_cast=character_cast or "",
) )
@@ -3060,17 +3475,21 @@ def _insta_of_cast_descriptors(
no_black: bool, no_black: bool,
women_count: int, women_count: int,
men_count: int, men_count: int,
character_cast: str | dict[str, Any] | list[Any] | None = "",
) -> list[str]: ) -> list[str]:
descriptors = [f"Woman A / primary creator: {primary_descriptor}"] descriptors, _slots = _cast_descriptor_entries(
rng = _axis_rng(seed_config, "person", seed, row_number + 997) seed_config,
for index in range(max(0, women_count - 1)): seed,
label = chr(ord("B") + index) row_number,
context = _appearance_for_subject(rng, "woman", ethnicity, figure, no_plus_women, no_black) ethnicity,
descriptors.append(f"Woman {label}: {_insta_of_descriptor_from_context(context)}") figure,
for index in range(max(0, men_count)): no_plus_women,
label = chr(ord("A") + index) no_black,
context = _appearance_for_subject(rng, "man", ethnicity, figure, no_plus_women, no_black) women_count,
descriptors.append(f"Man {label}: {_insta_of_descriptor_from_context(context)}") men_count,
character_cast,
primary_descriptor=primary_descriptor,
)
return descriptors return descriptors
@@ -3164,6 +3583,7 @@ def build_insta_of_pair(
filter_config: str | dict[str, Any] | None = None, filter_config: 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 = "", character_profile: str | dict[str, Any] | None = "",
character_cast: str | dict[str, Any] | list[Any] | None = "",
extra_positive: str = "", extra_positive: str = "",
extra_negative: str = "", extra_negative: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -3177,9 +3597,24 @@ def build_insta_of_pair(
hard_women_count, hard_men_count = _insta_of_hardcore_counts(options) hard_women_count, hard_men_count = _insta_of_hardcore_counts(options)
active_trigger = trigger.strip() or g.TRIGGER active_trigger = trigger.strip() or g.TRIGGER
parsed_seed_config = _parse_seed_config(seed_config) parsed_seed_config = _parse_seed_config(seed_config)
character_slots = _parse_character_cast(character_cast)
character_slot_map = _character_slot_label_map(character_slots)
softcore_level_key = str(options["softcore_level"]) softcore_level_key = str(options["softcore_level"])
soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key) soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key)
soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311) soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311)
soft_person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number)
primary_slot_context = None
primary_slot = character_slot_map.get("Woman A")
if primary_slot:
primary_slot_context = _context_from_character_slot(
soft_person_rng,
primary_slot,
"woman",
ethnicity,
figure,
no_plus_women,
no_black,
)
soft_row = build_prompt( soft_row = build_prompt(
category=soft_category, category=soft_category,
@@ -3204,8 +3639,13 @@ 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 "", character_profile="" if primary_slot else character_profile or "",
character_cast="",
) )
if primary_slot_context:
soft_row = _apply_character_context_to_row(soft_row, primary_slot_context)
soft_row["character_slot"] = primary_slot
soft_row["character_slot_status"] = "applied:Woman A"
soft_row["item"] = _insta_of_softcore_outfit(soft_content_rng, softcore_level_key) soft_row["item"] = _insta_of_softcore_outfit(soft_content_rng, softcore_level_key)
soft_row["pose"] = _insta_of_softcore_pose(soft_content_rng, softcore_level_key) soft_row["pose"] = _insta_of_softcore_pose(soft_content_rng, softcore_level_key)
soft_row["item_label"] = "Insta/OF softcore outfit" soft_row["item_label"] = "Insta/OF softcore outfit"
@@ -3234,6 +3674,7 @@ def build_insta_of_pair(
women_count=hard_women_count, women_count=hard_women_count,
men_count=hard_men_count, men_count=hard_men_count,
expression_intensity=options["hardcore_expression_intensity"], expression_intensity=options["hardcore_expression_intensity"],
character_cast=character_cast or "",
) )
descriptor = _insta_of_descriptor(soft_row) descriptor = _insta_of_descriptor(soft_row)
@@ -3248,6 +3689,7 @@ def build_insta_of_pair(
no_black, no_black,
hard_women_count, hard_women_count,
hard_men_count, hard_men_count,
character_slots,
) )
cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors)) cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors))
soft_cast_descriptor_text = ( soft_cast_descriptor_text = (
@@ -3382,6 +3824,8 @@ def build_insta_of_pair(
"hardcore_row": hard_row, "hardcore_row": hard_row,
"hardcore_women_count": hard_women_count, "hardcore_women_count": hard_women_count,
"hardcore_men_count": hard_men_count, "hardcore_men_count": hard_men_count,
"character_cast_slots": character_slots,
"character_slot_labels": sorted(character_slot_map),
"softcore_camera_config": soft_camera_config, "softcore_camera_config": soft_camera_config,
"hardcore_camera_config": hard_camera_config, "hardcore_camera_config": hard_camera_config,
"softcore_camera_directive": soft_camera_directive, "softcore_camera_directive": soft_camera_directive,