8886 lines
357 KiB
Python
8886 lines
357 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
from string import Formatter
|
|
from typing import Any, Callable
|
|
|
|
try:
|
|
from . import generate_prompt_batches as g
|
|
from .prompt_hygiene import (
|
|
sanitize_caption_text,
|
|
sanitize_negative_text,
|
|
sanitize_prompt_text,
|
|
)
|
|
except ImportError: # Allows local smoke tests with `python -c`.
|
|
import generate_prompt_batches as g
|
|
from prompt_hygiene import (
|
|
sanitize_caption_text,
|
|
sanitize_negative_text,
|
|
sanitize_prompt_text,
|
|
)
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent
|
|
CATEGORY_DIR = ROOT_DIR / "categories"
|
|
PROFILE_DIR = ROOT_DIR / "profiles"
|
|
|
|
BUILTIN_CATEGORIES = [
|
|
"auto_weighted",
|
|
"auto_full",
|
|
"woman",
|
|
"man",
|
|
"couple",
|
|
"group_or_layout",
|
|
"custom_random",
|
|
]
|
|
RANDOM_SUBCATEGORY = "random"
|
|
SEED_AXIS_SALTS = {
|
|
"category": 31,
|
|
"subcategory": 37,
|
|
"content": 41,
|
|
"person": 43,
|
|
"scene": 47,
|
|
"pose": 53,
|
|
"role": 57,
|
|
"expression": 59,
|
|
"composition": 61,
|
|
}
|
|
SEED_AXIS_ALIASES = {
|
|
"category": ("category_seed", "category"),
|
|
"subcategory": ("subcategory_seed", "subcategory"),
|
|
"content": ("content_seed", "item_seed", "outfit_seed", "sexual_pose_seed", "content"),
|
|
"person": ("person_seed", "appearance_seed", "cast_seed", "person"),
|
|
"scene": ("scene_seed", "scene"),
|
|
"pose": ("pose_seed", "sexual_pose_seed", "pose"),
|
|
"role": ("role_seed", "role", "pose_seed", "sexual_pose_seed"),
|
|
"expression": ("expression_seed", "face_seed", "expression"),
|
|
"composition": ("composition_seed", "camera_seed", "composition"),
|
|
}
|
|
|
|
SEED_LOCK_AXES = (
|
|
"category",
|
|
"subcategory",
|
|
"content",
|
|
"person",
|
|
"scene",
|
|
"pose",
|
|
"role",
|
|
"expression",
|
|
"composition",
|
|
)
|
|
SEED_MODE_CHOICES = ["auto", "follow_main", "fixed", "random"]
|
|
|
|
ETHNICITY_FILTER_CHOICES = [
|
|
"any",
|
|
"european",
|
|
"mediterranean_mena",
|
|
"latina",
|
|
"east_asian",
|
|
"southeast_asian",
|
|
"south_asian",
|
|
"black_african",
|
|
"indigenous",
|
|
"mixed",
|
|
"asian",
|
|
"white_asian",
|
|
"western_european",
|
|
"french_european",
|
|
"germanic_european",
|
|
"nordic_european",
|
|
"celtic_european",
|
|
"slavic_european",
|
|
"baltic_european",
|
|
"alpine_european",
|
|
"balkan_european",
|
|
"greek_mediterranean",
|
|
"italian_mediterranean",
|
|
"iberian_mediterranean",
|
|
]
|
|
ETHNICITY_LIST_KEYS = tuple(choice for choice in ETHNICITY_FILTER_CHOICES if choice != "any")
|
|
ETHNICITY_BASE_LIST_KEYS = (
|
|
"european",
|
|
"mediterranean_mena",
|
|
"latina",
|
|
"east_asian",
|
|
"southeast_asian",
|
|
"south_asian",
|
|
"black_african",
|
|
"indigenous",
|
|
"mixed",
|
|
)
|
|
EUROPEAN_REGIONAL_LIST_KEYS = (
|
|
"western_european",
|
|
"french_european",
|
|
"germanic_european",
|
|
"nordic_european",
|
|
"celtic_european",
|
|
"slavic_european",
|
|
"baltic_european",
|
|
"alpine_european",
|
|
"balkan_european",
|
|
)
|
|
MEDITERRANEAN_REGIONAL_LIST_KEYS = (
|
|
"greek_mediterranean",
|
|
"italian_mediterranean",
|
|
"iberian_mediterranean",
|
|
)
|
|
|
|
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_WOMAN_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",
|
|
]
|
|
CHARACTER_MAN_BODY_CHOICES = [
|
|
"random",
|
|
"manual",
|
|
"slim",
|
|
"lean",
|
|
"lean athletic",
|
|
"toned",
|
|
"average",
|
|
"athletic",
|
|
"muscular",
|
|
"broad",
|
|
"broad-shouldered",
|
|
"stocky",
|
|
"heavyset",
|
|
"fat",
|
|
]
|
|
CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"]
|
|
CHARACTER_PRESENCE_CHOICES = ["visible", "pov"]
|
|
CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
|
|
CHARACTER_SLOT_SEED_MAX = 0xFFFFFFFF
|
|
CHARACTER_HAIR_COLOR_CHOICES = [
|
|
"random",
|
|
"black",
|
|
"brown",
|
|
"dark_brown",
|
|
"chestnut",
|
|
"auburn",
|
|
"copper",
|
|
"red",
|
|
"blonde",
|
|
"platinum_blonde",
|
|
"ash_blonde",
|
|
"honey_blonde",
|
|
"strawberry_blonde",
|
|
"dark_blonde",
|
|
"silver_gray",
|
|
"white",
|
|
]
|
|
CHARACTER_HAIR_LENGTH_CHOICES = [
|
|
"random",
|
|
"very_short",
|
|
"short",
|
|
"bob_lob",
|
|
"shoulder_length",
|
|
"medium",
|
|
"long",
|
|
"very_long",
|
|
"updo",
|
|
]
|
|
CHARACTER_HAIR_STYLE_CHOICES = [
|
|
"random",
|
|
"straight",
|
|
"waves",
|
|
"loose_waves",
|
|
"curls",
|
|
"tight_curls",
|
|
"pixie_cut",
|
|
"bob",
|
|
"lob",
|
|
"shag",
|
|
"ponytail",
|
|
"braid",
|
|
"braids",
|
|
"bun",
|
|
"messy_bun",
|
|
"locs",
|
|
"twists",
|
|
"afro",
|
|
"natural_curls",
|
|
"wet_hair",
|
|
"slicked_back",
|
|
]
|
|
CHARACTER_EYE_COLOR_CHOICES = [
|
|
"random",
|
|
"blue",
|
|
"pale_blue",
|
|
"ice_blue",
|
|
"blue_gray",
|
|
"green",
|
|
"emerald_green",
|
|
"hazel",
|
|
"light_hazel",
|
|
"green_hazel",
|
|
"amber",
|
|
"amber_brown",
|
|
"honey_brown",
|
|
"brown",
|
|
"deep_brown",
|
|
"dark_brown",
|
|
"dark",
|
|
"gray",
|
|
"gray_brown",
|
|
]
|
|
|
|
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
|
|
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
|
|
HARDCORE_POSITION_FAMILY_CHOICES = [
|
|
"any",
|
|
"penetrative",
|
|
"foreplay",
|
|
"interaction",
|
|
"manual",
|
|
"oral",
|
|
"outercourse",
|
|
"anal",
|
|
"climax",
|
|
"threesome",
|
|
"group",
|
|
]
|
|
HARDCORE_POSITION_FOCUS_CHOICES = [
|
|
"keep_pool",
|
|
"penetration_only",
|
|
"foreplay_only",
|
|
"interaction_only",
|
|
"manual_only",
|
|
"oral_only",
|
|
"outercourse_only",
|
|
"anal_only",
|
|
"climax_only",
|
|
"threesome_only",
|
|
"group_only",
|
|
]
|
|
HARDCORE_POSITION_KEY_CHOICES = [
|
|
"missionary",
|
|
"cowgirl",
|
|
"reverse_cowgirl",
|
|
"doggy",
|
|
"bent_over",
|
|
"face_down_ass_up",
|
|
"standing",
|
|
"side_lying",
|
|
"edge_supported",
|
|
"kneeling",
|
|
"lotus_lap",
|
|
"face_sitting",
|
|
"sixty_nine",
|
|
"reclining_oral",
|
|
"straddled_oral",
|
|
"spread_leg_oral",
|
|
"chair_oral",
|
|
"kissing",
|
|
"caressing",
|
|
"breast_touch",
|
|
"face_touch",
|
|
"undressing",
|
|
"body_worship",
|
|
"nipple_play",
|
|
"ass_grab",
|
|
"thigh_kissing",
|
|
"hair_holding",
|
|
"wrist_pinning",
|
|
"dirty_talk",
|
|
"position_transition",
|
|
"guided_positioning",
|
|
"camera_showing",
|
|
"watching",
|
|
"aftercare",
|
|
"cleanup",
|
|
"fingering",
|
|
"clit_rubbing",
|
|
"mutual_masturbation",
|
|
"boobjob",
|
|
"testicle_sucking",
|
|
"penis_licking",
|
|
"handjob",
|
|
"footjob",
|
|
"open_thighs",
|
|
"front_back",
|
|
]
|
|
HARDCORE_POSITION_FAMILY_SUBCATEGORIES = {
|
|
"any": [
|
|
"penetrative_sex",
|
|
"foreplay_teasing",
|
|
"body_worship_touching",
|
|
"clothing_position_transitions",
|
|
"dominant_guidance",
|
|
"camera_performance",
|
|
"manual_stimulation",
|
|
"oral_sex",
|
|
"outercourse_sex",
|
|
"anal_double_penetration",
|
|
"threesomes",
|
|
"group_coordination",
|
|
"group_sex_orgy",
|
|
"cumshot_climax",
|
|
"aftercare_cleanup",
|
|
],
|
|
"penetrative": ["penetrative_sex"],
|
|
"foreplay": ["foreplay_teasing"],
|
|
"interaction": [
|
|
"foreplay_teasing",
|
|
"body_worship_touching",
|
|
"clothing_position_transitions",
|
|
"dominant_guidance",
|
|
"camera_performance",
|
|
"group_coordination",
|
|
"aftercare_cleanup",
|
|
],
|
|
"manual": ["manual_stimulation"],
|
|
"oral": ["oral_sex"],
|
|
"outercourse": ["outercourse_sex", "manual_stimulation"],
|
|
"anal": ["anal_double_penetration"],
|
|
"climax": ["cumshot_climax"],
|
|
"threesome": ["threesomes"],
|
|
"group": ["group_sex_orgy"],
|
|
}
|
|
HARDCORE_POSITION_KEY_MATCHES = {
|
|
"missionary": ("missionary", "above her", "under her"),
|
|
"cowgirl": ("cowgirl", "straddling", "straddles", "on top", "squatting on top"),
|
|
"reverse_cowgirl": ("reverse cowgirl", "facing away"),
|
|
"doggy": ("doggy", "all fours", "rear-entry", "from behind"),
|
|
"bent_over": ("bent-over", "bent over", "hips raised"),
|
|
"face_down_ass_up": ("face-down", "ass-up"),
|
|
"standing": ("standing", "stands", "braced standing"),
|
|
"side_lying": ("side-lying", "side lying", "spooning", "on the side", "on her side"),
|
|
"edge_supported": ("edge-of-bed", "edge of bed", "bed edge", "raised edge", "edge-supported"),
|
|
"kneeling": ("kneeling", "kneels", "kneeling center"),
|
|
"lotus_lap": ("lotus", "lap", "seated in a partner's lap"),
|
|
"face_sitting": ("face-sitting", "face sitting"),
|
|
"sixty_nine": ("sixty-nine", "69"),
|
|
"reclining_oral": ("reclining cunnilingus",),
|
|
"straddled_oral": ("straddled oral",),
|
|
"spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"),
|
|
"chair_oral": ("chair oral",),
|
|
"kissing": ("kiss", "kissing", "mouth-to-mouth", "mouth to mouth", "lips pressed"),
|
|
"caressing": ("caress", "caressing", "hands roaming", "stroking skin", "hands sliding"),
|
|
"breast_touch": ("breast", "breasts", "nipple", "cupping breasts", "touching breasts"),
|
|
"face_touch": ("face", "cheek", "jaw", "chin", "hand on the cheek", "fingers under the chin"),
|
|
"undressing": ("undressing", "removing clothing", "removing clothes", "pulling clothing", "sliding straps", "unbuttoning"),
|
|
"body_worship": ("body worship", "worship", "kissing down", "mouth on skin", "kissing the body"),
|
|
"nipple_play": ("nipple", "nipples", "licking nipples", "sucking nipples", "nipple play"),
|
|
"ass_grab": ("ass grab", "ass-grab", "ass grabbing", "hand on the ass", "squeezing the ass"),
|
|
"thigh_kissing": ("thigh kiss", "thigh kissing", "kissing thighs", "mouth on inner thighs"),
|
|
"hair_holding": ("hair holding", "hair held", "holding hair", "hair pulled back"),
|
|
"wrist_pinning": ("wrist", "wrists", "pinning wrists", "wrists pinned", "hands pinned"),
|
|
"dirty_talk": ("dirty talk", "whispering", "mouth near the ear", "telling", "verbal teasing"),
|
|
"position_transition": ("transition", "turning around", "pulling onto the bed", "moving into position", "position change"),
|
|
"guided_positioning": ("guiding", "guided", "guides", "lifting legs", "spreading thighs", "pulling hips", "turning the body"),
|
|
"camera_showing": ("camera", "showing to camera", "presenting to camera", "spread open for camera", "creator-shot"),
|
|
"watching": ("watching", "voyeur", "waiting turn", "partner watches", "onlooker"),
|
|
"aftercare": ("aftercare", "cuddling", "kissing after", "holding close", "post-sex"),
|
|
"cleanup": ("cleanup", "wiping", "cleaning", "towel", "wet cloth"),
|
|
"fingering": ("fingering", "fingers inside", "fingers in pussy", "finger stimulation"),
|
|
"clit_rubbing": ("clit", "clitoris", "clit rubbing", "rubbing the clit", "fingers on clit"),
|
|
"mutual_masturbation": ("mutual masturbation", "both touching themselves", "masturbating together", "hands on their own bodies"),
|
|
"boobjob": ("boobjob", "titjob", "breast-sex", "breast sex"),
|
|
"testicle_sucking": ("testicle", "balls-licking", "balls licking", "balls and mouth"),
|
|
"penis_licking": ("penis-licking", "penis licking", "tongue along", "tongue licking"),
|
|
"handjob": ("handjob", "hand job", "stroking the penis", "hand stroking", "manual stimulation"),
|
|
"footjob": ("footjob", "soles", "toes curled", "feet stroking"),
|
|
"open_thighs": ("thighs open", "legs spread", "open thighs", "legs open", "reclining with thighs open"),
|
|
"front_back": ("front-and-back", "front and back", "one behind and one in front", "between two partners"),
|
|
}
|
|
HARDCORE_POSITION_AXIS_KEYS = {
|
|
"position",
|
|
"body_position",
|
|
"body_arrangement",
|
|
"arrangement",
|
|
"tease_act",
|
|
"touch_detail",
|
|
"manual_act",
|
|
"manual_detail",
|
|
"worship_act",
|
|
"transition_act",
|
|
"control_act",
|
|
"performance_act",
|
|
"coordination_act",
|
|
"aftercare_act",
|
|
"cleanup_detail",
|
|
}
|
|
CAMERA_ORBIT_FRAMING_CHOICES = [
|
|
"from_zoom",
|
|
"wide",
|
|
"medium",
|
|
"full_body",
|
|
"three_quarter",
|
|
"close_up",
|
|
"extreme_close_up",
|
|
]
|
|
CAMERA_ORBIT_FOCUS_CHOICES = [
|
|
"auto",
|
|
"face",
|
|
"torso",
|
|
"hips",
|
|
"full_body",
|
|
"action",
|
|
"contact_points",
|
|
"environment",
|
|
]
|
|
|
|
GENERIC_POSITIVE_SUFFIX = (
|
|
"Use crisp clean comic linework, detailed hatching, soft blended shading, "
|
|
"pastel skin tones, muted blues and pinks, warm sensual lighting, and tactile textured paper."
|
|
)
|
|
|
|
SINGLE_TEMPLATE = (
|
|
"A {subject}: {style}, {age}, {body_phrase}, {skin}, {hair}, {eyes}. "
|
|
"{item_label}: {item}. Scene: {scene}. Pose: {pose}. Facial expression: {expression}. "
|
|
"Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}."
|
|
)
|
|
|
|
COUPLE_TEMPLATE = (
|
|
"{subject_phrase}: {style}. Ages: {age}. Body types: {body}. {item_label}: {item}. "
|
|
"Scene: {scene}. Pose: {pose}. Facial expressions: {expression}. "
|
|
"Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}."
|
|
)
|
|
|
|
GROUP_TEMPLATE = (
|
|
"{subject_phrase}: {style}, ages {age}, diverse adult body types. {item_label}: {item}. "
|
|
"Scene: {scene}. Facial expressions: {expression}. Composition: {composition_prompt}. "
|
|
"{positive_suffix} Avoid: {negative_prompt}."
|
|
)
|
|
|
|
LAYOUT_TEMPLATE = (
|
|
"{item}: {style}, adults only, clean designed composition. Scene: {scene}. "
|
|
"Facial expression: {expression}. Composition: {composition}. {positive_suffix} "
|
|
"Avoid: {negative_prompt}. Use no readable text unless the layout naturally needs small decorative placeholder marks."
|
|
)
|
|
|
|
CAMERA_MODE_PROMPTS = {
|
|
"disabled": "",
|
|
"standard": "",
|
|
"handheld_selfie": (
|
|
"Camera mode: handheld smartphone selfie, close arm-length framing, visible creator-shot perspective, "
|
|
"slight wide-angle intimacy, direct eye contact, natural phone-camera composition."
|
|
),
|
|
"mirror_selfie": (
|
|
"Camera mode: mirror selfie with the phone visible in one hand, reflective framing, creator looking at the screen, "
|
|
"body and environment visible through the mirror."
|
|
),
|
|
"phone_tripod": (
|
|
"Camera mode: phone on tripod or ring-light stand, creator-facing social-video framing, stable vertical composition, "
|
|
"hands-free self-recorded setup."
|
|
),
|
|
"creator_pov": (
|
|
"Camera mode: creator-held POV, intimate subscriber-view angle, the creator controls the camera, close foreground body framing."
|
|
),
|
|
"bed_selfie": (
|
|
"Camera mode: bed selfie shot from a phone held above or beside the body, intimate close framing, sheets visible around the subject."
|
|
),
|
|
"bathroom_mirror": (
|
|
"Camera mode: bathroom mirror selfie, phone visible, tiled private room, close vertical framing, candid creator-shot energy."
|
|
),
|
|
"phone_flash": (
|
|
"Camera mode: direct phone-flash selfie, crisp flash highlights, candid night-post feeling, hard-edged smartphone shadows."
|
|
),
|
|
"action_cam": (
|
|
"Camera mode: body-mounted or handheld action-camera intimacy, very close wide-angle perspective, dynamic creator-shot framing."
|
|
),
|
|
}
|
|
|
|
CAMERA_COMPACT_LABELS = {
|
|
"disabled": "",
|
|
"standard": "",
|
|
"handheld_selfie": "handheld smartphone selfie",
|
|
"mirror_selfie": "mirror selfie",
|
|
"phone_tripod": "phone tripod / ring-light setup",
|
|
"creator_pov": "creator-held POV",
|
|
"bed_selfie": "bed selfie",
|
|
"bathroom_mirror": "bathroom mirror selfie",
|
|
"phone_flash": "phone-flash selfie",
|
|
"action_cam": "handheld action-camera view",
|
|
"full_body": "full body",
|
|
"three_quarter": "three-quarter body",
|
|
"waist_up": "waist-up",
|
|
"close_up": "close-up",
|
|
"extreme_close_up": "extreme close-up",
|
|
"eye_level": "eye-level",
|
|
"high_angle": "high-angle",
|
|
"low_angle": "low-angle",
|
|
"overhead": "overhead",
|
|
"side_profile": "side-profile",
|
|
"rear_view": "rear-view",
|
|
"mirror_reflection": "mirror reflection",
|
|
"smartphone_wide": "smartphone wide-angle",
|
|
"ultra_wide": "ultra-wide",
|
|
"portrait_lens": "phone portrait lens",
|
|
"telephoto": "telephoto-style",
|
|
"macro_detail": "macro detail",
|
|
"arm_length": "arm-length",
|
|
"near_body": "near-body",
|
|
"bedside": "bedside phone",
|
|
"room_corner": "room-corner phone",
|
|
"vertical_story": "vertical 9:16",
|
|
"square_feed": "square feed",
|
|
"horizontal": "horizontal",
|
|
"phone_visible": "phone visible",
|
|
"phone_hidden": "phone hidden",
|
|
"screen_reflection": "screen reflection",
|
|
"ring_light_visible": "ring light visible",
|
|
}
|
|
|
|
CAMERA_SHOT_PROMPTS = {
|
|
"auto": "",
|
|
"full_body": "Shot size: full body visible, head-to-toe framing, no important body parts cropped out.",
|
|
"three_quarter": "Shot size: three-quarter body framing, face, torso, hips, and thighs clearly visible.",
|
|
"waist_up": "Shot size: waist-up creator framing with face and upper body as the focus.",
|
|
"close_up": "Shot size: close-up framing with face, expression, hands, and body contact emphasized.",
|
|
"extreme_close_up": "Shot size: extreme close-up detail shot, tightly framed and intimate.",
|
|
}
|
|
|
|
CAMERA_ANGLE_PROMPTS = {
|
|
"auto": "",
|
|
"eye_level": "Angle: eye-level camera angle with direct creator eye contact.",
|
|
"high_angle": "Angle: high-angle selfie looking down toward the body.",
|
|
"low_angle": "Angle: low-angle phone camera looking upward from near the body.",
|
|
"overhead": "Angle: overhead phone shot looking down at the full pose.",
|
|
"side_profile": "Angle: side-profile camera view emphasizing body silhouette and contact points.",
|
|
"rear_view": "Angle: rear-view camera framing with the body turned away from the lens.",
|
|
"mirror_reflection": "Angle: mirror-reflection composition with the phone and reflected body placement readable.",
|
|
}
|
|
|
|
CAMERA_LENS_PROMPTS = {
|
|
"auto": "",
|
|
"smartphone_wide": "Lens: smartphone wide-angle lens with slight edge distortion and close personal scale.",
|
|
"ultra_wide": "Lens: ultra-wide phone lens, exaggerated near-camera perspective, environmental context visible.",
|
|
"portrait_lens": "Lens: phone portrait mode, shallow depth of field, crisp subject separation.",
|
|
"telephoto": "Lens: compressed telephoto-style framing, flatter proportions, less distortion.",
|
|
"macro_detail": "Lens: macro-detail phone shot focused on texture, skin, fabric, and contact detail.",
|
|
}
|
|
|
|
CAMERA_DISTANCE_PROMPTS = {
|
|
"auto": "",
|
|
"arm_length": "Camera distance: arm-length selfie distance, close enough to feel handheld.",
|
|
"near_body": "Camera distance: near-body camera placement with intimate foreground framing.",
|
|
"bedside": "Camera distance: phone placed beside the body on the bed or floor.",
|
|
"room_corner": "Camera distance: phone set across the room, self-recorded but wider and more observational.",
|
|
}
|
|
|
|
CAMERA_ORIENTATION_PROMPTS = {
|
|
"auto": "",
|
|
"vertical_story": "Orientation: vertical 9:16 story/reel framing.",
|
|
"square_feed": "Orientation: square social-feed crop.",
|
|
"horizontal": "Orientation: horizontal phone-video crop.",
|
|
}
|
|
|
|
CAMERA_PHONE_PROMPTS = {
|
|
"auto": "",
|
|
"phone_visible": "Phone visibility: phone visible in hand or mirror, clearly creator-shot.",
|
|
"phone_hidden": "Phone visibility: phone is implied but not visible, preserving the selfie/creator-shot perspective.",
|
|
"screen_reflection": "Phone visibility: screen glow or reflection visible in the scene.",
|
|
"ring_light_visible": "Phone visibility: ring light or tripod visible enough to read as self-recorded content.",
|
|
}
|
|
|
|
CAMERA_PRIORITY_PROMPTS = {
|
|
"soft_hint": "Camera priority: treat the camera notes as style guidance.",
|
|
"strong": "Camera priority: strongly preserve the selected camera, lens, angle, crop, and phone-shot perspective.",
|
|
"locked": "Camera priority: locked camera constraint; do not replace this with a studio, third-person, cinematic, or unrelated camera view.",
|
|
}
|
|
|
|
|
|
_EXTENSIONS_APPLIED = False
|
|
|
|
|
|
class SafeFormatDict(dict):
|
|
def __missing__(self, key: str) -> str:
|
|
return "{" + key + "}"
|
|
|
|
|
|
def _json_files() -> list[Path]:
|
|
if not CATEGORY_DIR.exists():
|
|
return []
|
|
return sorted(path for path in CATEGORY_DIR.glob("*.json") if path.is_file())
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"{path} must contain a JSON object")
|
|
return data
|
|
|
|
|
|
def _slug(value: str) -> str:
|
|
return g.slugify(value) or "custom"
|
|
|
|
|
|
def _list_from(value: Any) -> list[Any]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return value
|
|
return [value]
|
|
|
|
|
|
def _is_false(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value is False
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in ("false", "0", "no", "off")
|
|
return False
|
|
|
|
|
|
def _unique_extend(target: list[Any], additions: list[Any]) -> None:
|
|
seen = set()
|
|
for item in target:
|
|
try:
|
|
seen.add(json.dumps(item, sort_keys=True))
|
|
except TypeError:
|
|
seen.add(repr(item))
|
|
for item in additions:
|
|
try:
|
|
marker = json.dumps(item, sort_keys=True)
|
|
except TypeError:
|
|
marker = repr(item)
|
|
if marker not in seen:
|
|
target.append(item)
|
|
seen.add(marker)
|
|
|
|
|
|
def _pair_from(value: Any) -> tuple[str, str]:
|
|
if isinstance(value, dict):
|
|
text = str(
|
|
value.get("prompt")
|
|
or value.get("description")
|
|
or value.get("text")
|
|
or value.get("name")
|
|
or ""
|
|
).strip()
|
|
slug = str(value.get("slug") or _slug(str(value.get("name") or text))).strip()
|
|
if not text:
|
|
raise ValueError(f"Pair extension is missing prompt text: {value!r}")
|
|
return slug, text
|
|
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
return str(value[0]), str(value[1])
|
|
text = str(value).strip()
|
|
if not text:
|
|
raise ValueError("Pair extension cannot be empty")
|
|
return _slug(text), text
|
|
|
|
|
|
def _weighted_choice(rng: random.Random, items: list[Any]) -> Any:
|
|
if not items:
|
|
raise ValueError("Cannot choose from an empty list")
|
|
weights: list[float] = []
|
|
for item in items:
|
|
weight = item.get("weight", 1.0) if isinstance(item, dict) else 1.0
|
|
try:
|
|
weights.append(max(0.0, float(weight)))
|
|
except (TypeError, ValueError):
|
|
weights.append(1.0)
|
|
total = sum(weights)
|
|
if total <= 0:
|
|
return items[rng.randrange(len(items))]
|
|
pick = rng.random() * total
|
|
running = 0.0
|
|
for item, weight in zip(items, weights):
|
|
running += weight
|
|
if pick <= running:
|
|
return item
|
|
return items[-1]
|
|
|
|
|
|
def _entry_text(item: Any) -> str:
|
|
if isinstance(item, dict):
|
|
return str(
|
|
item.get("template")
|
|
or item.get("prompt")
|
|
or item.get("text")
|
|
or item.get("description")
|
|
or item.get("name")
|
|
or ""
|
|
).strip()
|
|
return str(item).strip()
|
|
|
|
|
|
def _item_text(item: Any) -> str:
|
|
return _entry_text(item)
|
|
|
|
|
|
def _item_name(item: Any) -> str:
|
|
if isinstance(item, dict):
|
|
return str(item.get("name") or _item_text(item)).strip()
|
|
return _item_text(item)
|
|
|
|
|
|
def _template_list(category: dict[str, Any], subcategory: dict[str, Any], item: Any, key: str) -> list[Any]:
|
|
if isinstance(item, dict) and key in item:
|
|
return _list_from(item[key])
|
|
if key in subcategory:
|
|
return _list_from(subcategory[key])
|
|
if key in category:
|
|
return _list_from(category[key])
|
|
return []
|
|
|
|
|
|
def _constraint_int(entry: dict[str, Any], key: str) -> int | None:
|
|
if key not in entry:
|
|
return None
|
|
try:
|
|
return int(entry[key])
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _cast_requirement_matches(requirement: str, women_count: int, men_count: int) -> bool:
|
|
total = women_count + men_count
|
|
requirement = requirement.strip().lower()
|
|
if requirement in ("", "any"):
|
|
return True
|
|
if requirement == "women_only":
|
|
return women_count > 0 and men_count == 0
|
|
if requirement == "men_only":
|
|
return men_count > 0 and women_count == 0
|
|
if requirement == "mixed":
|
|
return women_count > 0 and men_count > 0
|
|
if requirement == "has_women":
|
|
return women_count > 0
|
|
if requirement == "has_men":
|
|
return men_count > 0
|
|
if requirement == "solo":
|
|
return total == 1
|
|
if requirement == "couple":
|
|
return total == 2
|
|
if requirement == "threesome":
|
|
return total == 3
|
|
if requirement == "group":
|
|
return total >= 4
|
|
return True
|
|
|
|
|
|
def _is_toy_assisted_double_couple_text(text: str) -> bool:
|
|
text = text.lower()
|
|
if "toy" not in text:
|
|
return False
|
|
return any(
|
|
token in text
|
|
for token in (
|
|
"double penetration",
|
|
"double-penetration",
|
|
"vaginal and anal penetration",
|
|
"second penetration point",
|
|
"second point of contact",
|
|
"second contact",
|
|
)
|
|
)
|
|
|
|
|
|
def _heuristic_cast_compatible(text: str, women_count: int, men_count: int) -> bool:
|
|
text = text.lower()
|
|
if not text:
|
|
return True
|
|
total = women_count + men_count
|
|
if total == 2 and women_count == 1 and men_count == 1:
|
|
if "{double_act}" in text:
|
|
return False
|
|
if _is_toy_assisted_double_couple_text(text):
|
|
return False
|
|
if total == 1:
|
|
solo_blocked_terms = (
|
|
"partner",
|
|
"partners",
|
|
"two bodies",
|
|
"three bodies",
|
|
"bodies still pressed",
|
|
"bodies pressed",
|
|
"bodies tangled",
|
|
"wet bodies",
|
|
"chests heaving together",
|
|
"straddling a partner",
|
|
"shared climax",
|
|
"between two",
|
|
"from both sides",
|
|
"front-and-back",
|
|
"body contact",
|
|
)
|
|
if any(term in text for term in solo_blocked_terms):
|
|
return False
|
|
solo_toy_terms = ("toy", "dildo", "finger", "fingers", "self")
|
|
if "penetration" in text and not any(term in text for term in solo_toy_terms):
|
|
return False
|
|
if total < 3 and "threesome" in text:
|
|
return False
|
|
if total != 3 and ("centered threesome" in text or "three-way" in text):
|
|
return False
|
|
if total < 3 and ("three bodies" in text or "center partner" in text or "center body" in text):
|
|
return False
|
|
if total < 4 and ("orgy" in text or "group sex" in text or "group-sex" in text or "group pile" in text):
|
|
return False
|
|
if total < 3 and (
|
|
"double penetration" in text
|
|
or "two partners penetrating" in text
|
|
or "front-and-back penetration" in text
|
|
or "one penis in pussy and one penis in ass" in text
|
|
or "pussy and ass filled" in text
|
|
or "vaginal and anal penetration at the same time" in text
|
|
or "front-and-back double penetration" in text
|
|
or "hardcore double penetration" in text
|
|
or "kneeling double penetration" in text
|
|
or "standing supported double penetration" in text
|
|
or "deep double penetration" in text
|
|
or "between two partners" in text
|
|
or "from both sides" in text
|
|
):
|
|
toy_terms = ("strap-on", "strap on", "dildo", "toy", "finger")
|
|
if not any(term in text for term in toy_terms):
|
|
return False
|
|
if men_count == 0:
|
|
toy_terms = ("strap-on", "strap on", "dildo", "toy", "finger", "fingers")
|
|
penetration_terms = (
|
|
"vaginal penetration",
|
|
"deep vaginal sex",
|
|
"penetrative sex",
|
|
"pussy penetration",
|
|
"pussy stretched",
|
|
"vaginal thrusting",
|
|
"full-body penetrative",
|
|
"close-contact vaginal",
|
|
"penetration clearly visible",
|
|
"explicit penetrative contact",
|
|
)
|
|
if any(term in text for term in penetration_terms) and not any(term in text for term in toy_terms):
|
|
return False
|
|
male_terms = (
|
|
" penis",
|
|
"penis ",
|
|
"penises",
|
|
"cum",
|
|
"creampie",
|
|
"facial",
|
|
"blowjob",
|
|
"fellatio",
|
|
"deepthroat",
|
|
"ejaculation",
|
|
"semen",
|
|
)
|
|
if any(term in text for term in male_terms) and not any(term in text for term in toy_terms):
|
|
return False
|
|
elif men_count < 2 and "penises" in text:
|
|
return False
|
|
if women_count == 0:
|
|
if "penetrative sex" in text and not any(term in text for term in ("anal", "ass", "male/male", "men")):
|
|
return False
|
|
female_terms = (
|
|
"pussy",
|
|
"vaginal",
|
|
"vagina",
|
|
"cunnilingus",
|
|
"clit",
|
|
"clitoris",
|
|
"breasts",
|
|
"breast ",
|
|
"nipples",
|
|
"nipple",
|
|
"underboob",
|
|
)
|
|
if any(term in text for term in female_terms):
|
|
return False
|
|
return True
|
|
|
|
|
|
HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS = (
|
|
(r"\bstacked bodies on the bed\b", "close body alignment"),
|
|
(r"\bstacked bodies with close body alignment\b", "close body alignment"),
|
|
(r"\boverhead tangled-body anal frame\b", "overhead rear-entry anal frame"),
|
|
(r"\btangled-body\b", "close-body"),
|
|
(r"\bthree bodies tangled on the bed\b", "three bodies tangled in close contact"),
|
|
(r"\ba triangle of bodies on the mattress\b", "a triangle of bodies in close contact"),
|
|
(r"\bbodies tangled on the sheets\b", "bodies tangled in close contact"),
|
|
(r"\bwet bodies tangled on sheets\b", "wet bodies tangled in close contact"),
|
|
(r"\bbody arched on rumpled sheets\b", "body arched with clear skin contact"),
|
|
(r"\bface-down ass-up on the mattress\b", "face-down ass-up position"),
|
|
(r"\bsitting on the edge of the bed\b", "sitting on a raised edge"),
|
|
(r"\blying at the bed edge with thighs open\b", "lying near a raised edge with thighs open"),
|
|
(r"\bedge[- ]of[- ]bed\b", "edge-supported"),
|
|
(r"\bbed[- ]edge\b", "raised edge"),
|
|
(r"\bedge of (?:the )?bed\b", "raised edge"),
|
|
(r"\bbed edge\b", "raised edge"),
|
|
(r"\bhands? braced on the bed\b", "hands braced beside the body"),
|
|
(r"\bone hand pressing into the mattress\b", "one hand braced beside the body"),
|
|
(r"\bone foot planted on the bed\b", "one foot planted for leverage"),
|
|
(r"\bfingers gripping sheets and skin\b", "fingers gripping skin"),
|
|
(r"\bfingers gripping sheets\b", "fingers gripping skin"),
|
|
(r"\bhands gripping sheets\b", "hands gripping skin"),
|
|
(r"\bone hand gripping the sheets\b", "one hand gripping skin"),
|
|
(r"\brumpled bed sheets\b", "wrinkled body-contact fabric"),
|
|
(r"\bwet sheets beneath the bodies\b", "visible wetness beneath the bodies"),
|
|
(r"\bsexual fluids on sheets\b", "sexual fluids visible on skin"),
|
|
(r"\bcum dripping onto sheets\b", "cum visible on skin"),
|
|
(r"\bfluid dripping onto sheets\b", "fluid visible on skin"),
|
|
(r"\bsquirting fluid on the sheets\b", "squirting fluid visible on skin"),
|
|
(r"\bsoft sheets\b", "soft fabric"),
|
|
(r"\bsilk sheets\b", "silk fabric"),
|
|
(r"\bsheets\b", "fabric"),
|
|
(r"\bmattress\b", "low support surface"),
|
|
(r"\ba low support surface\b", "a low body support"),
|
|
(r"\ba low mattress\b", "a low body support"),
|
|
(r"\ba wide couch\b", "a wide body support"),
|
|
(r"\bwide couch\b", "wide body support"),
|
|
(r"\bcouch\b", "body support"),
|
|
(r"\bsofa\b", "body support"),
|
|
(r"\bon the bed\b", "on a body support"),
|
|
(r"\bon a bed\b", "on a body support"),
|
|
(r"\bbedroom-floor\b", "floor-level"),
|
|
(r"\bbedroom floor\b", "floor-level"),
|
|
)
|
|
|
|
|
|
def _sanitize_hardcore_environment_anchors(value: Any) -> str:
|
|
text = str(value or "")
|
|
if not text:
|
|
return ""
|
|
for pattern, replacement in HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS:
|
|
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\s+,", ",", text)
|
|
text = re.sub(r",\s*,", ",", text)
|
|
text = re.sub(r"\s{2,}", " ", text)
|
|
return text.strip()
|
|
|
|
|
|
def _sanitize_hardcore_axis_values(values: dict[str, str]) -> dict[str, str]:
|
|
return {key: _sanitize_hardcore_environment_anchors(value) for key, value in values.items()}
|
|
|
|
|
|
def _compatible_entry(entry: Any, women_count: int, men_count: int) -> bool:
|
|
if not isinstance(entry, dict):
|
|
return _heuristic_cast_compatible(_entry_text(entry), women_count, men_count)
|
|
total = women_count + men_count
|
|
for key, value in (
|
|
("min_women", women_count),
|
|
("min_men", men_count),
|
|
("min_people", total),
|
|
):
|
|
minimum = _constraint_int(entry, key)
|
|
if minimum is not None and value < minimum:
|
|
return False
|
|
for key, value in (
|
|
("max_women", women_count),
|
|
("max_men", men_count),
|
|
("max_people", total),
|
|
):
|
|
maximum = _constraint_int(entry, key)
|
|
if maximum is not None and value > maximum:
|
|
return False
|
|
requirements = _list_from(entry.get("cast", [])) + _list_from(entry.get("requires", []))
|
|
if requirements and not all(_cast_requirement_matches(str(req), women_count, men_count) for req in requirements):
|
|
return False
|
|
if any(key in entry for key in ("subcategories", "item_templates", "item_axes")):
|
|
return True
|
|
return _heuristic_cast_compatible(_entry_text(entry), women_count, men_count)
|
|
|
|
|
|
def _compatible_entries(entries: list[Any], women_count: int, men_count: int) -> list[Any]:
|
|
filtered = [entry for entry in entries if _compatible_entry(entry, women_count, men_count)]
|
|
return filtered or entries
|
|
|
|
|
|
def _merged_axes(category: dict[str, Any], subcategory: dict[str, Any], item: Any) -> dict[str, list[Any]]:
|
|
axes: dict[str, list[Any]] = {}
|
|
for source in (category, subcategory, item if isinstance(item, dict) else None):
|
|
if not isinstance(source, dict):
|
|
continue
|
|
raw_axes = source.get("item_axes", {})
|
|
if raw_axes is None:
|
|
continue
|
|
if not isinstance(raw_axes, dict):
|
|
raise ValueError("item_axes must be a JSON object")
|
|
for key, values in raw_axes.items():
|
|
axes[str(key)] = _list_from(values)
|
|
return axes
|
|
|
|
|
|
def _oral_acts_for_position(values: list[Any], position: str) -> list[Any]:
|
|
position_text = str(position or "").lower()
|
|
if not position_text:
|
|
return values
|
|
|
|
def act_text(value: Any) -> str:
|
|
return _entry_text(value).lower()
|
|
|
|
def filtered(predicate: Callable[[str], bool]) -> list[Any]:
|
|
matches = [value for value in values if predicate(act_text(value))]
|
|
return matches or values
|
|
|
|
penis_terms = ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth")
|
|
cunnilingus_terms = ("cunnilingus", "pussy licking", "tongue on pussy", "oral sex with tongue and fingers", "mouth on genitals")
|
|
if "sixty-nine" in position_text:
|
|
return filtered(lambda text: "sixty-nine" in text)
|
|
if "face-sitting" in position_text:
|
|
return filtered(lambda text: "face-sitting" in text or any(term in text for term in cunnilingus_terms))
|
|
if "kneeling oral" in position_text:
|
|
return filtered(lambda text: any(term in text for term in penis_terms))
|
|
if "straddled oral" in position_text or "reclining cunnilingus" in position_text:
|
|
return filtered(lambda text: "sixty-nine" not in text and not any(term in text for term in penis_terms))
|
|
if "spread-leg oral" in position_text:
|
|
return filtered(lambda text: "sixty-nine" not in text and "face-sitting" not in text)
|
|
if any(term in position_text for term in ("standing oral", "kneeling oral", "edge-of-bed oral", "chair oral", "side-lying oral")):
|
|
return filtered(lambda text: "sixty-nine" not in text and "face-sitting" not in text)
|
|
return values
|
|
|
|
|
|
def _oral_axis_values_for_context(values: list[Any], position: str, oral_act: str, axis_name: str) -> list[Any]:
|
|
axis_name = str(axis_name or "").lower()
|
|
if axis_name not in {"body_contact", "hand_detail", "mouth_detail", "saliva_detail", "climax_hint", "visibility"}:
|
|
return values
|
|
position_text = str(position or "").lower()
|
|
act_text = str(oral_act or "").lower()
|
|
woman_gives = any(
|
|
term in act_text
|
|
for term in ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth")
|
|
)
|
|
man_gives = any(
|
|
term in act_text
|
|
for term in ("cunnilingus", "pussy licking", "tongue on pussy")
|
|
)
|
|
if not (woman_gives or man_gives):
|
|
return values
|
|
|
|
def value_text(value: Any) -> str:
|
|
return _entry_text(value).lower()
|
|
|
|
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
|
|
matches = [
|
|
value
|
|
for value in values
|
|
if any(term in value_text(value) for term in terms)
|
|
and not any(term in value_text(value) for term in excluded_terms)
|
|
]
|
|
return matches or values
|
|
|
|
if woman_gives:
|
|
by_axis = {
|
|
"body_contact": ("hips pushed", "fingers tangled", "bodies stacked", "hands on thighs"),
|
|
"hand_detail": ("hips", "penis", "head", "hair"),
|
|
"mouth_detail": ("lips", "mouth", "deep mouth", "saliva"),
|
|
"saliva_detail": ("saliva", "wet lips", "slick wet mouth", "drool", "mouth"),
|
|
"climax_hint": ("mouth", "lips", "tongue", "breasts", "belly", "sexual fluids"),
|
|
"visibility": ("mouth", "penis", "oral"),
|
|
}
|
|
excluded = {
|
|
"body_contact": ("legs held open", "spread legs", "ass lifted", "chest pressed to thighs"),
|
|
"hand_detail": ("spreading thighs", "sheets", "cupping breasts", "pressing into thighs", "holding the ass"),
|
|
}
|
|
return filtered(by_axis.get(axis_name, ("mouth", "penis")), excluded.get(axis_name, ()))
|
|
if man_gives and ("kneeling oral" in position_text or "standing oral" in position_text):
|
|
by_axis = {
|
|
"body_contact": ("legs held open", "one body kneeling", "chest pressed", "ass lifted", "hands on thighs"),
|
|
"hand_detail": ("thigh", "hips", "head", "ass"),
|
|
"mouth_detail": ("tongue", "wet lips", "deep mouth", "genitals"),
|
|
"saliva_detail": ("saliva", "wet lips", "tongue", "drool"),
|
|
"climax_hint": ("sexual fluids", "orgasmic tension"),
|
|
"visibility": ("mouth", "pussy", "oral", "genital"),
|
|
}
|
|
return filtered(by_axis.get(axis_name, ("mouth", "pussy", "tongue")), ("penis", "breasts"))
|
|
return values
|
|
|
|
|
|
def _outercourse_acts_for_position(values: list[Any], position: str) -> list[Any]:
|
|
position_text = str(position or "").lower()
|
|
if not position_text:
|
|
return values
|
|
|
|
def act_text(value: Any) -> str:
|
|
return _entry_text(value).lower()
|
|
|
|
def filtered(predicate: Callable[[str], bool]) -> list[Any]:
|
|
matches = [value for value in values if predicate(act_text(value))]
|
|
return matches or values
|
|
|
|
if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")):
|
|
return filtered(lambda text: any(term in text for term in ("boobjob", "titjob", "breast sex", "breasts")))
|
|
if any(term in position_text for term in ("testicle", "balls")):
|
|
return filtered(lambda text: any(term in text for term in ("testicle", "balls")))
|
|
if "penis-licking" in position_text or "penis licking" in position_text:
|
|
return filtered(lambda text: "licking" in text or "tongue" in text)
|
|
if "handjob" in position_text or "hand job" in position_text:
|
|
return filtered(lambda text: any(term in text for term in ("handjob", "hand job", "hand wrapped", "two-handed")))
|
|
if "footjob" in position_text:
|
|
return filtered(lambda text: any(term in text for term in ("footjob", "feet", "soles", "toes")))
|
|
return values
|
|
|
|
|
|
def _outercourse_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]:
|
|
position_text = str(position or "").lower()
|
|
if not position_text:
|
|
return values
|
|
axis_name = str(axis_name or "").lower()
|
|
if axis_name not in {"contact_detail", "hand_detail", "texture_detail", "visibility", "body_contact"}:
|
|
return values
|
|
|
|
def value_text(value: Any) -> str:
|
|
return _entry_text(value).lower()
|
|
|
|
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
|
|
matches = [
|
|
value
|
|
for value in values
|
|
if any(term in value_text(value) for term in terms)
|
|
and not any(term in value_text(value) for term in excluded_terms)
|
|
]
|
|
return matches or values
|
|
|
|
if any(term in position_text for term in ("boobjob", "titjob", "breast-sex", "breast sex")):
|
|
by_axis = {
|
|
"contact_detail": ("compressed", "glans", "breast", "breasts", "soft tissue", "skin visibly"),
|
|
"hand_detail": ("breast", "breasts", "fingers"),
|
|
"texture_detail": ("compression", "soft flesh", "skin", "flesh", "asymmetry"),
|
|
"visibility": ("breast", "breasts", "glans", "shaft"),
|
|
"body_contact": ("torso", "body angled", "shoulders", "hips"),
|
|
}
|
|
excluded_by_axis = {
|
|
"contact_detail": ("hand wrapped", "fingers and palm", "soles", "toes", "balls", "tongue"),
|
|
"hand_detail": ("base of the penis", "penis shaft", "balls", "thigh", "ankles", "stroking"),
|
|
"texture_detail": ("toes", "soles", "tongue"),
|
|
"visibility": ("balls", "soles", "toes", "hand"),
|
|
"body_contact": ("head tucked", "face directly", "base of the penis"),
|
|
}
|
|
return filtered(
|
|
by_axis.get(axis_name, ("breast", "breasts", "shaft")),
|
|
excluded_by_axis.get(axis_name, ()),
|
|
)
|
|
if any(term in position_text for term in ("testicle", "balls")):
|
|
by_axis = {
|
|
"contact_detail": ("balls", "lips", "tongue", "wet"),
|
|
"hand_detail": ("balls", "base", "thigh"),
|
|
"texture_detail": ("wet", "saliva", "skin"),
|
|
"visibility": ("balls", "mouth"),
|
|
"body_contact": ("torso", "shoulders", "head tucked", "base of the penis", "knees", "thigh"),
|
|
}
|
|
return filtered(by_axis.get(axis_name, ("balls", "mouth", "tongue")))
|
|
if "penis-licking" in position_text or "penis licking" in position_text:
|
|
by_axis = {
|
|
"contact_detail": ("tongue", "lips", "glans", "shaft", "wet"),
|
|
"hand_detail": ("base", "penis", "thigh"),
|
|
"texture_detail": ("wet", "saliva", "skin"),
|
|
"visibility": ("tongue", "penis"),
|
|
"body_contact": ("head low", "face directly", "torso", "pelvis", "base of the penis", "hips", "body angled"),
|
|
}
|
|
return filtered(by_axis.get(axis_name, ("tongue", "glans", "shaft")))
|
|
if "handjob" in position_text or "hand job" in position_text:
|
|
by_axis = {
|
|
"contact_detail": ("hand", "fingers", "palm", "shaft", "glans"),
|
|
"hand_detail": ("hand", "hands", "shaft", "penis"),
|
|
"texture_detail": ("fingers", "pressure", "skin", "shaft"),
|
|
"visibility": ("hand", "penis", "shaft", "glans"),
|
|
"body_contact": ("hips", "knees", "body angle"),
|
|
}
|
|
return filtered(by_axis.get(axis_name, ("hand", "penis", "shaft")))
|
|
if "footjob" in position_text:
|
|
by_axis = {
|
|
"contact_detail": ("soles", "toes", "shaft"),
|
|
"hand_detail": ("ankles", "thighs"),
|
|
"texture_detail": ("toes", "soles", "pressure"),
|
|
"visibility": ("feet", "soles"),
|
|
"body_contact": ("legs", "knees", "body angled"),
|
|
}
|
|
return filtered(by_axis.get(axis_name, ("feet", "soles", "toes")))
|
|
return values
|
|
|
|
|
|
def _compose_item(
|
|
rng: random.Random,
|
|
category: dict[str, Any],
|
|
subcategory: dict[str, Any],
|
|
item: Any,
|
|
women_count: int = 1,
|
|
men_count: int = 1,
|
|
) -> tuple[str, str, dict[str, str]]:
|
|
templates = _template_list(category, subcategory, item, "item_templates")
|
|
axes = _merged_axes(category, subcategory, item)
|
|
if templates and axes:
|
|
template = _entry_text(_weighted_choice(rng, _compatible_entries(templates, women_count, men_count)))
|
|
fields = [key for _, key, _, _ in Formatter().parse(template) if key]
|
|
unique_fields = list(dict.fromkeys(fields))
|
|
axis_values: dict[str, str] = {}
|
|
subcategory_slug = str(subcategory.get("slug") or "").lower()
|
|
if subcategory_slug in ("oral_sex", "outercourse_sex") and "position" in unique_fields and axes.get("position"):
|
|
position_values = _compatible_entries(axes["position"], women_count, men_count)
|
|
axis_values["position"] = _entry_text(_weighted_choice(rng, position_values))
|
|
for name in unique_fields:
|
|
if name in axis_values or name not in axes or not axes[name]:
|
|
continue
|
|
values = _compatible_entries(axes[name], women_count, men_count)
|
|
if subcategory_slug == "oral_sex" and name == "oral_act":
|
|
values = _oral_acts_for_position(values, axis_values.get("position", ""))
|
|
elif subcategory_slug == "oral_sex":
|
|
values = _oral_axis_values_for_context(
|
|
values,
|
|
axis_values.get("position", ""),
|
|
axis_values.get("oral_act", ""),
|
|
name,
|
|
)
|
|
if subcategory_slug == "outercourse_sex" and name == "outer_act":
|
|
values = _outercourse_acts_for_position(values, axis_values.get("position", ""))
|
|
if subcategory_slug == "outercourse_sex":
|
|
values = _outercourse_axis_values_for_position(values, axis_values.get("position", ""), name)
|
|
axis_values[name] = _entry_text(_weighted_choice(rng, values))
|
|
item_text = _format(template, axis_values).strip()
|
|
item_name = _item_name(item) or subcategory["name"]
|
|
return item_text, item_name, axis_values
|
|
return _item_text(item), _item_name(item), {}
|
|
|
|
|
|
def _choose_text(rng: random.Random, items: list[Any]) -> str:
|
|
item = _weighted_choice(rng, items)
|
|
return _item_text(item)
|
|
|
|
|
|
def _choose_distinct_text(rng: random.Random, items: list[Any], first_text: str) -> str:
|
|
first_text = _item_text(first_text).lower()
|
|
distinct = [item for item in items if _item_text(item).lower() != first_text]
|
|
if not distinct:
|
|
return ""
|
|
return _choose_text(rng, distinct)
|
|
|
|
|
|
def _choose_pair(rng: random.Random, items: list[Any]) -> tuple[str, str]:
|
|
return _pair_from(_weighted_choice(rng, items))
|
|
|
|
|
|
def _normalize_subcategories(category: dict[str, Any]) -> list[dict[str, Any]]:
|
|
raw = category.get("subcategories", [])
|
|
if isinstance(raw, dict):
|
|
raw = [
|
|
{"name": name, **(value if isinstance(value, dict) else {"items": value})}
|
|
for name, value in raw.items()
|
|
]
|
|
subcategories: list[dict[str, Any]] = []
|
|
for entry in _list_from(raw):
|
|
if isinstance(entry, str):
|
|
sub = {"name": entry, "items": [entry]}
|
|
elif isinstance(entry, dict):
|
|
sub = dict(entry)
|
|
else:
|
|
raise ValueError(f"Subcategory must be an object or string: {entry!r}")
|
|
name = str(sub.get("name") or sub.get("slug") or "General").strip()
|
|
sub["name"] = name
|
|
sub["slug"] = str(sub.get("slug") or _slug(name))
|
|
if "items" not in sub and "prompts" in sub:
|
|
sub["items"] = sub["prompts"]
|
|
if "items" not in sub:
|
|
sub["items"] = [name]
|
|
subcategories.append(sub)
|
|
if not subcategories:
|
|
name = str(category.get("name") or "General")
|
|
subcategories.append({"name": "General", "slug": "general", "items": [name]})
|
|
return subcategories
|
|
|
|
|
|
def _normalize_categories(raw_categories: Any) -> list[dict[str, Any]]:
|
|
if isinstance(raw_categories, dict):
|
|
iterable = [
|
|
{"name": name, **(value if isinstance(value, dict) else {"subcategories": value})}
|
|
for name, value in raw_categories.items()
|
|
]
|
|
else:
|
|
iterable = _list_from(raw_categories)
|
|
|
|
categories: list[dict[str, Any]] = []
|
|
for entry in iterable:
|
|
if not isinstance(entry, dict):
|
|
raise ValueError(f"Category must be an object: {entry!r}")
|
|
category = dict(entry)
|
|
name = str(category.get("name") or category.get("slug") or "Custom").strip()
|
|
category["name"] = name
|
|
category["slug"] = str(category.get("slug") or _slug(name))
|
|
category["subcategories"] = _normalize_subcategories(category)
|
|
categories.append(category)
|
|
return categories
|
|
|
|
|
|
def load_category_library() -> list[dict[str, Any]]:
|
|
categories: list[dict[str, Any]] = []
|
|
for path in _json_files():
|
|
data = _read_json(path)
|
|
categories.extend(_normalize_categories(data.get("categories", [])))
|
|
return categories
|
|
|
|
|
|
def _load_named_pool_library(key: str) -> dict[str, list[Any]]:
|
|
pools: dict[str, list[Any]] = {}
|
|
for path in _json_files():
|
|
data = _read_json(path)
|
|
raw_pools = data.get(key, {})
|
|
if not raw_pools:
|
|
continue
|
|
if not isinstance(raw_pools, dict):
|
|
raise ValueError(f"{key} in {path} must be an object")
|
|
for name, entries in raw_pools.items():
|
|
pool_name = str(name).strip()
|
|
if not pool_name:
|
|
continue
|
|
pools.setdefault(pool_name, [])
|
|
_unique_extend(pools[pool_name], _list_from(entries))
|
|
return pools
|
|
|
|
|
|
def load_scene_pool_library() -> dict[str, list[Any]]:
|
|
return _load_named_pool_library("scene_pools")
|
|
|
|
|
|
LOCATION_POOL_PRESETS = {
|
|
"custom_only": (),
|
|
"all_json_locations": ("*",),
|
|
"casual_all": ("casual_",),
|
|
"casual_urban": ("casual_urban_scenes",),
|
|
"casual_summer": ("casual_summer_scenes",),
|
|
"casual_home": ("casual_lounge_scenes",),
|
|
"casual_smart": ("casual_smart_scenes",),
|
|
"creator_softcore": ("softcore_creator_scenes", "mirror_scenes", "boudoir_bedroom_scenes"),
|
|
"mirror_rooms": ("mirror_scenes", "hardcore_mirror_scenes"),
|
|
"boudoir_bedroom": ("boudoir_bedroom_scenes", "hardcore_bed_scenes"),
|
|
"fetish_studio": ("fetish_studio_scenes",),
|
|
"costume_backstage": ("costume_backstage_scenes",),
|
|
"hardcore_all": ("hardcore_",),
|
|
"hardcore_private": ("hardcore_private_scenes",),
|
|
"hardcore_bed": ("hardcore_bed_scenes",),
|
|
"hardcore_penetrative": ("hardcore_penetrative_scenes",),
|
|
"hardcore_oral": ("hardcore_oral_scenes",),
|
|
"hardcore_anal": ("hardcore_anal_scenes",),
|
|
"hardcore_threesome": ("hardcore_threesome_scenes",),
|
|
"hardcore_group": ("hardcore_group_scenes",),
|
|
"hardcore_climax": ("hardcore_climax_scenes",),
|
|
}
|
|
|
|
|
|
def location_pool_preset_choices() -> list[str]:
|
|
pool_choices = [f"pool:{key}" for key in sorted(load_scene_pool_library())]
|
|
return list(LOCATION_POOL_PRESETS) + pool_choices
|
|
|
|
|
|
def load_expression_pool_library() -> dict[str, list[Any]]:
|
|
return _load_named_pool_library("expression_pools")
|
|
|
|
|
|
def load_composition_pool_library() -> dict[str, list[Any]]:
|
|
return _load_named_pool_library("composition_pools")
|
|
|
|
|
|
COMPOSITION_POOL_PRESETS = {
|
|
"custom_only": (),
|
|
"all_json_compositions": ("*",),
|
|
"casual_all": ("casual_", "streetwear_", "summer_", "cozy_home_", "smart_casual_", "athleisure_"),
|
|
"creator_softcore": ("softcore_creator_compositions", "boudoir_body_compositions"),
|
|
"hardcore_all": ("hardcore_",),
|
|
"hardcore_explicit": ("hardcore_explicit_compositions",),
|
|
"no_outfit_check": (),
|
|
}
|
|
|
|
|
|
COMPOSITION_INLINE_PRESETS = {
|
|
"no_outfit_check": [
|
|
"environment-led frame with no outfit-check wording",
|
|
"mid-distance scene composition with the room context readable",
|
|
"partly occluded candid frame through foreground architecture",
|
|
"long perspective frame using repeating background structure",
|
|
"waist-up or three-quarter frame without bag, shoes, or footwear emphasis",
|
|
],
|
|
}
|
|
|
|
|
|
def composition_pool_preset_choices() -> list[str]:
|
|
pool_choices = [f"pool:{key}" for key in sorted(load_composition_pool_library())]
|
|
return list(COMPOSITION_POOL_PRESETS) + pool_choices
|
|
|
|
|
|
THEMATIC_LOCATION_PRESETS = {
|
|
"classical_library": {
|
|
"locations": [
|
|
{"slug": "classical_large_library", "prompt": "grand classical library hall with towering dark-wood bookshelves, carved columns, rolling ladders, marble floor, warm brass lamps, arched windows, and deep quiet academic atmosphere"},
|
|
{"slug": "old_world_reading_room", "prompt": "large old-world reading room with floor-to-ceiling bookshelves, heavy wooden tables, green banker lamps, leather chairs, tall arched windows, and warm amber evening light"},
|
|
{"slug": "hidden_library_stacks", "prompt": "quiet library stacks with endless tall bookshelves, narrow aisles, rolling ladders, brass lamps, and hidden sightlines between shelves"},
|
|
],
|
|
"compositions": [
|
|
"narrow aisle frame between towering bookshelves",
|
|
"over-the-shoulder view through foreground books",
|
|
"warm lamp-lit reading-table composition",
|
|
"long vanishing-point frame down repeated library stacks",
|
|
"partly hidden frame behind carved columns and shelf edges",
|
|
],
|
|
},
|
|
"semi_public_affair": {
|
|
"locations": [
|
|
{"slug": "hotel_corridor_affair", "prompt": "upscale hotel corridor with repeating numbered doors, patterned carpet, brass wall lamps, luggage carts, and a secluded corner near a service alcove"},
|
|
{"slug": "hotel_service_hall", "prompt": "luxury hotel service corridor with repeating linen carts, beige doors, utility shelves, wall sconces, and a private turn away from the main hallway"},
|
|
{"slug": "parking_garage_hidden", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted floor lines, low fluorescent light, and shadowed blind spots"},
|
|
{"slug": "office_afterhours_affair", "prompt": "empty corporate office after hours with rows of glass partitions, repeating desks, blinds, copier alcove, muted city light, and no visible coworkers"},
|
|
{"slug": "library_stacks_secret", "prompt": "classical library stacks with endless tall bookshelves, narrow aisles, rolling ladders, carved columns, warm brass lamps, and hidden sightlines between shelves"},
|
|
],
|
|
"compositions": [
|
|
"partly concealed frame from behind a doorway edge",
|
|
"long corridor vanishing-point composition with repeated doors",
|
|
"hidden alcove frame with foreground obstruction",
|
|
"surveillance-like candid angle from across the empty space",
|
|
"tight frame using pillars, shelves, or walls to block side visibility",
|
|
],
|
|
},
|
|
"hotel_corridor": {
|
|
"locations": [
|
|
{"slug": "upscale_hotel_corridor", "prompt": "upscale hotel corridor with repeating doors, patterned carpet, brass wall lamps, quiet service alcoves, and warm late-night light"},
|
|
{"slug": "hotel_service_alcove", "prompt": "hotel service alcove with linen carts, beige utility doors, folded towels, soft wall sconces, and a secluded turn off the main corridor"},
|
|
{"slug": "boutique_hotel_stair_landing", "prompt": "boutique hotel stair landing with repeating railings, framed wall panels, low amber lamps, and a quiet corner between floors"},
|
|
],
|
|
"compositions": [
|
|
"long hallway frame with repeated doors receding behind the body",
|
|
"corner-alcove composition partly hidden by a wall edge",
|
|
"low corridor angle with patterned carpet leading lines",
|
|
"over-the-shoulder frame toward a closed hotel-room door",
|
|
],
|
|
},
|
|
"parking_garage": {
|
|
"locations": [
|
|
{"slug": "empty_parking_garage", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted bay lines, low fluorescent light, and deep shadowed corners"},
|
|
{"slug": "underground_garage_corner", "prompt": "underground parking garage corner with numbered pillars, glossy concrete floor, parked cars, and blue-green fluorescent light"},
|
|
{"slug": "rooftop_parking_deck_night", "prompt": "rooftop parking deck at night with repeated concrete barriers, distant city lights, painted lines, and open wind"},
|
|
],
|
|
"compositions": [
|
|
"pillar-framed composition with repeated concrete columns",
|
|
"low angle across painted parking lines",
|
|
"hidden corner frame between parked cars",
|
|
"wide empty garage frame with strong fluorescent perspective",
|
|
],
|
|
},
|
|
"theater_backstage": {
|
|
"locations": [
|
|
{"slug": "old_theater_backstage", "prompt": "old theater backstage with repeated velvet curtains, prop racks, costume rails, bulb mirrors, dark wings, and narrow hidden passages"},
|
|
{"slug": "cabaret_backstage_wings", "prompt": "cabaret backstage wings with red curtains, costume racks, vanity bulbs, stage ropes, and warm theatrical shadows"},
|
|
{"slug": "prop_storage_corridor", "prompt": "theater prop storage corridor with stacked trunks, repeated scenery flats, rolling racks, and dim practical lamps"},
|
|
],
|
|
"compositions": [
|
|
"frame between layered velvet curtains",
|
|
"backstage mirror-bulb composition with costume racks behind",
|
|
"hidden wing angle looking toward the stage light spill",
|
|
"narrow prop-aisle frame with repeated vertical flats",
|
|
],
|
|
},
|
|
"wine_cellar": {
|
|
"locations": [
|
|
{"slug": "private_wine_cellar", "prompt": "private wine cellar with repeating bottle racks, arched brick walls, narrow aisles, dim amber lamps, and secluded corners between shelves"},
|
|
{"slug": "restaurant_wine_storage", "prompt": "restaurant wine storage room with stacked bottle shelves, crate rows, stone floor, soft utility light, and hidden service-door access"},
|
|
{"slug": "arched_cellar_corridor", "prompt": "arched cellar corridor with repeated brick niches, wine racks, low golden lamps, and cool shadowed depth"},
|
|
],
|
|
"compositions": [
|
|
"narrow aisle frame between repeated bottle racks",
|
|
"arched brick corridor composition with warm lamps",
|
|
"foreground bottle-rack occlusion framing the body",
|
|
"low cellar angle with shelves receding behind",
|
|
],
|
|
},
|
|
"museum_archive": {
|
|
"locations": [
|
|
{"slug": "museum_archive_room", "prompt": "museum archive room with repeating storage shelves, labeled boxes, rolling ladders, long work tables, soft overhead lights, and hidden aisles"},
|
|
{"slug": "gallery_storage_backroom", "prompt": "gallery storage backroom with stacked frames, rolling racks, crate labels, clean concrete floor, and muted work lights"},
|
|
{"slug": "rare_books_archive", "prompt": "rare-books archive with compact shelving, catalog drawers, reading lamps, archival boxes, and narrow private aisles"},
|
|
],
|
|
"compositions": [
|
|
"hidden archive-aisle frame between storage shelves",
|
|
"table-edge composition with labeled boxes in the background",
|
|
"foreground crate or shelf occlusion",
|
|
"long compact-shelving perspective with repeated rows",
|
|
],
|
|
},
|
|
"laundromat_late_night": {
|
|
"locations": [
|
|
{"slug": "late_night_laundromat", "prompt": "late-night laundromat with repeating washing machines, chrome reflections, tiled floor, fluorescent lights, empty aisles, and a secluded back corner"},
|
|
{"slug": "coin_laundry_back_row", "prompt": "coin laundry back row with stacked dryers, plastic folding tables, detergent shelves, buzzing fluorescent light, and no other customers"},
|
|
{"slug": "laundromat_mirror_windows", "prompt": "quiet laundromat with mirrored machine doors, repeated round windows, tile floor, and cool blue night light through front glass"},
|
|
],
|
|
"compositions": [
|
|
"repeating washer-door perspective behind the body",
|
|
"folding-table edge frame with chrome reflections",
|
|
"low tiled-floor angle down an empty machine row",
|
|
"back-corner composition partly hidden by laundry machines",
|
|
],
|
|
},
|
|
"train_station_lockers": {
|
|
"locations": [
|
|
{"slug": "train_station_locker_corridor", "prompt": "quiet train-station locker corridor with repeating metal lockers, tiled walls, vending machines, fluorescent light, and a hidden side alcove"},
|
|
{"slug": "empty_platform_underpass", "prompt": "empty station underpass with tiled walls, repeated poster frames, stair railings, fluorescent lights, and late-night quiet"},
|
|
{"slug": "station_service_passage", "prompt": "station service passage with repeating utility doors, metal lockers, warning stripes, and cool overhead light"},
|
|
],
|
|
"compositions": [
|
|
"locker-row vanishing-point composition",
|
|
"side-alcove frame partly blocked by metal lockers",
|
|
"fluorescent underpass frame with repeated tile lines",
|
|
"candid angle from behind a vending machine edge",
|
|
],
|
|
},
|
|
"nightclub_back_hall": {
|
|
"locations": [
|
|
{"slug": "nightclub_back_hall", "prompt": "nightclub back hallway with black doors, repeated neon strips, coat-check racks, textured walls, and distant colored dance-floor light"},
|
|
{"slug": "club_vip_corridor", "prompt": "VIP club corridor with velvet ropes, mirrored wall panels, low red light, repeated booths, and a private bend in the hallway"},
|
|
{"slug": "music_venue_greenroom_hall", "prompt": "music venue greenroom corridor with stickered doors, cable cases, dim practical lamps, and repeated black curtains"},
|
|
],
|
|
"compositions": [
|
|
"neon hallway frame with repeated dark doors",
|
|
"partly hidden VIP-booth angle",
|
|
"mirror-panel composition with colored light streaks",
|
|
"tight backstage corridor frame with curtains at the edges",
|
|
],
|
|
},
|
|
"restaurant_private_booth": {
|
|
"locations": [
|
|
{"slug": "restaurant_private_booth", "prompt": "dim restaurant private booth with high banquettes, repeating table lamps, dark wood partitions, folded napkins, and secluded sightlines"},
|
|
{"slug": "empty_bistro_back_corner", "prompt": "empty bistro back corner with tiled floor, small round tables, brass lamps, mirrored walls, and a hidden booth"},
|
|
{"slug": "afterhours_dining_room", "prompt": "after-hours dining room with stacked chairs, repeated tables, low amber sconces, and a quiet service doorway"},
|
|
],
|
|
"compositions": [
|
|
"booth-partition frame with high seat backs blocking the sides",
|
|
"table-edge composition with lamps repeating behind",
|
|
"mirror-wall restaurant angle with dark wood partitions",
|
|
"after-hours dining-room perspective through empty tables",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def location_theme_choices() -> list[str]:
|
|
return list(THEMATIC_LOCATION_PRESETS)
|
|
|
|
|
|
def _extension_targets() -> dict[str, tuple[list[Any], bool]]:
|
|
return {
|
|
"women_clothes": (g.WOMEN_CLOTHES, False),
|
|
"women_clothes_minimal": (g.WOMEN_CLOTHES_MINIMAL, False),
|
|
"men_clothes": (g.MEN_CLOTHES, False),
|
|
"men_clothes_minimal": (g.MEN_CLOTHES_MINIMAL, False),
|
|
"couple_outfits": (g.COUPLE_OUTFITS, False),
|
|
"couple_outfits_minimal": (g.COUPLE_OUTFITS_MINIMAL, False),
|
|
"poses": (g.POSES, False),
|
|
"evocative_poses": (g.EVOCATIVE_POSES, False),
|
|
"backside_poses": (g.BACKSIDE_POSES, False),
|
|
"expressions": (g.EXPRESSIONS, False),
|
|
"compositions": (g.COMPOSITIONS, False),
|
|
"props": (g.PROPS, False),
|
|
"figure_curvy": (g.FIGURE_CURVY, False),
|
|
"figure_athletic": (g.FIGURE_ATHLETIC, False),
|
|
"figure_bombshell": (g.FIGURE_BOMBSHELL, False),
|
|
"scenes": (g.SCENES, True),
|
|
"group_scenes": (g.GROUP_SCENES, True),
|
|
"layouts_full": (g.LAYOUTS_FULL, True),
|
|
"layouts_minimal": (g.LAYOUTS_MINIMAL, True),
|
|
"group_compositions": (g.GROUP_COMPOSITIONS, False),
|
|
"group_ages": (g.GROUP_AGES, False),
|
|
}
|
|
|
|
|
|
def apply_pool_extensions() -> None:
|
|
global _EXTENSIONS_APPLIED
|
|
if _EXTENSIONS_APPLIED:
|
|
return
|
|
targets = _extension_targets()
|
|
for path in _json_files():
|
|
data = _read_json(path)
|
|
extensions = data.get("pool_extensions", {})
|
|
if not isinstance(extensions, dict):
|
|
raise ValueError(f"pool_extensions in {path} must be an object")
|
|
for target_name, additions in extensions.items():
|
|
if target_name not in targets:
|
|
known = ", ".join(sorted(targets))
|
|
raise ValueError(f"Unknown pool extension '{target_name}' in {path}. Known: {known}")
|
|
target, expects_pair = targets[target_name]
|
|
normalized = [_pair_from(item) for item in _list_from(additions)] if expects_pair else [
|
|
_item_text(item) for item in _list_from(additions)
|
|
]
|
|
_unique_extend(target, normalized)
|
|
g.EVOCATIVE_ALL = g.EVOCATIVE_POSES + g.BACKSIDE_POSES
|
|
_EXTENSIONS_APPLIED = True
|
|
|
|
|
|
def category_choices() -> list[str]:
|
|
apply_pool_extensions()
|
|
custom = [category["name"] for category in load_category_library()]
|
|
return BUILTIN_CATEGORIES + [name for name in custom if name not in BUILTIN_CATEGORIES]
|
|
|
|
|
|
def subcategory_choices() -> list[str]:
|
|
apply_pool_extensions()
|
|
choices = [RANDOM_SUBCATEGORY]
|
|
for category in load_category_library():
|
|
for subcategory in category["subcategories"]:
|
|
choices.append(f"{category['name']} / {subcategory['name']}")
|
|
return choices
|
|
|
|
|
|
def seed_mode_choices() -> list[str]:
|
|
return list(SEED_MODE_CHOICES)
|
|
|
|
|
|
CATEGORY_PRESETS = {
|
|
"auto_weighted": ("auto_weighted", RANDOM_SUBCATEGORY),
|
|
"auto_full": ("auto_full", 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_enabled": True,
|
|
"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_enabled": True,
|
|
"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_enabled": True,
|
|
"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_enabled": True,
|
|
"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_enabled": True,
|
|
"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_enabled": True,
|
|
"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_mode: 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",
|
|
expression_enabled: bool = True,
|
|
) -> str:
|
|
profile = profile if profile in GENERATION_PROFILE_PRESETS else "balanced"
|
|
config = dict(GENERATION_PROFILE_PRESETS[profile])
|
|
if clothing_override in ("full", "minimal", "random"):
|
|
config["clothing"] = clothing_override
|
|
if poses_override in ("standard", "evocative", "random"):
|
|
config["poses"] = poses_override
|
|
config["expression_enabled"] = not _is_false(expression_enabled)
|
|
if expression_intensity_mode == "random":
|
|
config["expression_intensity"] = -1.0
|
|
elif expression_intensity_mode == "fixed" and 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", "random") else "full"
|
|
parsed["poses"] = parsed["poses"] if parsed.get("poses") in ("standard", "evocative", "random") else "standard"
|
|
parsed["expression_enabled"] = not _is_false(parsed.get("expression_enabled", True))
|
|
try:
|
|
raw_expression_intensity = float(parsed.get("expression_intensity"))
|
|
except (TypeError, ValueError):
|
|
raw_expression_intensity = 0.5
|
|
parsed["expression_intensity"] = -1.0 if raw_expression_intensity < 0 else _clamped_float(raw_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,
|
|
include_european: bool = True,
|
|
include_mediterranean_mena: bool = True,
|
|
include_latina: bool = True,
|
|
include_east_asian: bool = True,
|
|
include_southeast_asian: bool = True,
|
|
include_south_asian: bool = True,
|
|
include_black_african: bool = True,
|
|
include_indigenous: bool = True,
|
|
include_mixed: bool = True,
|
|
include_plus_size: bool = True,
|
|
) -> str:
|
|
include_flags = {
|
|
"european": include_european,
|
|
"mediterranean_mena": include_mediterranean_mena,
|
|
"latina": include_latina,
|
|
"east_asian": include_east_asian,
|
|
"southeast_asian": include_southeast_asian,
|
|
"south_asian": include_south_asian,
|
|
"black_african": include_black_african,
|
|
"indigenous": include_indigenous,
|
|
"mixed": include_mixed,
|
|
}
|
|
selected_ethnicities = [key for key, enabled in include_flags.items() if enabled]
|
|
disabled_ethnicities = [key for key, enabled in include_flags.items() if not enabled]
|
|
enabled_ethnicities = list(selected_ethnicities)
|
|
if enabled_ethnicities:
|
|
enabled_ethnicities.extend(f"exclude_{key}" for key in disabled_ethnicities)
|
|
if 0 < len(selected_ethnicities) < len(include_flags):
|
|
ethnicity = "+".join(enabled_ethnicities)
|
|
elif not _is_valid_ethnicity_filter(ethnicity):
|
|
ethnicity = "any"
|
|
return json.dumps(
|
|
{
|
|
"ethnicity": ethnicity,
|
|
"ethnicity_includes": selected_ethnicities,
|
|
"figure": figure if figure in ("curvy", "balanced", "bombshell", "random") else "curvy",
|
|
"include_plus_size": bool(include_plus_size),
|
|
"include_black_african": bool(include_black_african),
|
|
"no_plus_women": not bool(include_plus_size) or bool(no_plus_women),
|
|
"no_black": not bool(include_black_african) or bool(no_black),
|
|
},
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def _location_pool_names_for_preset(preset: str) -> list[str]:
|
|
scene_pools = load_scene_pool_library()
|
|
preset = str(preset or "custom_only")
|
|
if preset.startswith("pool:"):
|
|
pool_name = preset.split(":", 1)[1].strip()
|
|
return [pool_name] if pool_name in scene_pools else []
|
|
selectors = LOCATION_POOL_PRESETS.get(preset, ())
|
|
names: list[str] = []
|
|
for selector in selectors:
|
|
if selector == "*":
|
|
_unique_extend(names, sorted(scene_pools))
|
|
elif selector.endswith("_"):
|
|
_unique_extend(names, sorted(name for name in scene_pools if name.startswith(selector)))
|
|
elif selector in scene_pools:
|
|
_unique_extend(names, [selector])
|
|
return names
|
|
|
|
|
|
def _custom_location_entries(custom_locations: str) -> list[dict[str, str]]:
|
|
entries: list[dict[str, str]] = []
|
|
for raw_line in str(custom_locations or "").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
slug = ""
|
|
prompt = line
|
|
if ":" in line:
|
|
maybe_slug, maybe_prompt = line.split(":", 1)
|
|
if maybe_slug.strip() and maybe_prompt.strip():
|
|
slug = _slug(maybe_slug)
|
|
prompt = maybe_prompt.strip()
|
|
prompt = prompt.strip()
|
|
if prompt:
|
|
entries.append({"slug": slug or _slug(prompt), "prompt": prompt})
|
|
return entries
|
|
|
|
|
|
def _scene_entries_for_pool_names(pool_names: list[str]) -> list[Any]:
|
|
scene_pools = load_scene_pool_library()
|
|
entries: list[Any] = []
|
|
for pool_name in pool_names:
|
|
if pool_name not in scene_pools:
|
|
continue
|
|
_unique_extend(entries, scene_pools[pool_name])
|
|
return entries
|
|
|
|
|
|
def build_location_pool_json(
|
|
enabled: bool = True,
|
|
combine_mode: str = "replace",
|
|
preset: str = "custom_only",
|
|
custom_locations: str = "",
|
|
location_config: str | dict[str, Any] | None = "",
|
|
) -> str:
|
|
incoming = _parse_location_config(location_config)
|
|
combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace"
|
|
pool_names = _location_pool_names_for_preset(preset)
|
|
entries = _scene_entries_for_pool_names(pool_names)
|
|
_unique_extend(entries, _custom_location_entries(custom_locations))
|
|
|
|
if combine_mode == "add" and incoming.get("enabled"):
|
|
apply_mode = str(incoming.get("apply_mode") or "replace")
|
|
merged_pool_names = _list_from(incoming.get("pool_names"))
|
|
_unique_extend(merged_pool_names, pool_names)
|
|
merged_entries = _list_from(incoming.get("scene_entries"))
|
|
_unique_extend(merged_entries, entries)
|
|
else:
|
|
apply_mode = "replace" if combine_mode == "replace" else "add"
|
|
merged_pool_names = pool_names
|
|
merged_entries = entries
|
|
|
|
active = bool(enabled) and bool(merged_entries)
|
|
summary = (
|
|
f"{apply_mode}; pools={len(merged_pool_names)}; locations={len(merged_entries)}"
|
|
if active
|
|
else "disabled or empty"
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"enabled": active,
|
|
"apply_mode": apply_mode,
|
|
"pool_names": merged_pool_names,
|
|
"scene_entries": merged_entries,
|
|
"summary": summary,
|
|
},
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def _parse_location_config(location_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
if not location_config:
|
|
return {"enabled": False, "apply_mode": "replace", "pool_names": [], "scene_entries": []}
|
|
if isinstance(location_config, dict):
|
|
raw = dict(location_config)
|
|
else:
|
|
try:
|
|
raw = json.loads(str(location_config))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid location_config JSON: {exc}") from exc
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("location_config must be a JSON object")
|
|
entries = _list_from(raw.get("scene_entries"))
|
|
if not entries and raw.get("pool_names"):
|
|
entries = _scene_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))])
|
|
return {
|
|
"enabled": bool(raw.get("enabled")) and bool(entries),
|
|
"apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace",
|
|
"pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()],
|
|
"scene_entries": entries,
|
|
"summary": str(raw.get("summary") or ""),
|
|
}
|
|
|
|
|
|
def _location_config_active(location_config: dict[str, Any]) -> bool:
|
|
return bool(location_config.get("enabled")) and bool(location_config.get("scene_entries"))
|
|
|
|
|
|
def _composition_pool_names_for_preset(preset: str) -> list[str]:
|
|
composition_pools = load_composition_pool_library()
|
|
preset = str(preset or "custom_only")
|
|
if preset.startswith("pool:"):
|
|
pool_name = preset.split(":", 1)[1].strip()
|
|
return [pool_name] if pool_name in composition_pools else []
|
|
selectors = COMPOSITION_POOL_PRESETS.get(preset, ())
|
|
names: list[str] = []
|
|
for selector in selectors:
|
|
if selector == "*":
|
|
_unique_extend(names, sorted(composition_pools))
|
|
elif selector.endswith("_"):
|
|
_unique_extend(names, sorted(name for name in composition_pools if name.startswith(selector)))
|
|
elif selector in composition_pools:
|
|
_unique_extend(names, [selector])
|
|
return names
|
|
|
|
|
|
def _custom_composition_entries(custom_compositions: str) -> list[str]:
|
|
entries: list[str] = []
|
|
for raw_line in str(custom_compositions or "").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
entries.append(line)
|
|
return entries
|
|
|
|
|
|
def _composition_entries_for_pool_names(pool_names: list[str]) -> list[Any]:
|
|
composition_pools = load_composition_pool_library()
|
|
entries: list[Any] = []
|
|
for pool_name in pool_names:
|
|
if pool_name not in composition_pools:
|
|
continue
|
|
_unique_extend(entries, composition_pools[pool_name])
|
|
return entries
|
|
|
|
|
|
def build_composition_pool_json(
|
|
enabled: bool = True,
|
|
combine_mode: str = "replace",
|
|
preset: str = "custom_only",
|
|
custom_compositions: str = "",
|
|
composition_config: str | dict[str, Any] | None = "",
|
|
) -> str:
|
|
incoming = _parse_composition_config(composition_config)
|
|
combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace"
|
|
pool_names = _composition_pool_names_for_preset(preset)
|
|
entries = _composition_entries_for_pool_names(pool_names)
|
|
_unique_extend(entries, COMPOSITION_INLINE_PRESETS.get(str(preset or ""), []))
|
|
_unique_extend(entries, _custom_composition_entries(custom_compositions))
|
|
|
|
if combine_mode == "add" and incoming.get("enabled"):
|
|
apply_mode = str(incoming.get("apply_mode") or "replace")
|
|
merged_pool_names = _list_from(incoming.get("pool_names"))
|
|
_unique_extend(merged_pool_names, pool_names)
|
|
merged_entries = _list_from(incoming.get("composition_entries"))
|
|
_unique_extend(merged_entries, entries)
|
|
else:
|
|
apply_mode = "replace" if combine_mode == "replace" else "add"
|
|
merged_pool_names = pool_names
|
|
merged_entries = entries
|
|
|
|
active = bool(enabled) and bool(merged_entries)
|
|
summary = (
|
|
f"{apply_mode}; pools={len(merged_pool_names)}; compositions={len(merged_entries)}"
|
|
if active
|
|
else "disabled or empty"
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"enabled": active,
|
|
"apply_mode": apply_mode,
|
|
"pool_names": merged_pool_names,
|
|
"composition_entries": merged_entries,
|
|
"summary": summary,
|
|
},
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def _parse_composition_config(composition_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
if not composition_config:
|
|
return {"enabled": False, "apply_mode": "replace", "pool_names": [], "composition_entries": []}
|
|
if isinstance(composition_config, dict):
|
|
raw = dict(composition_config)
|
|
else:
|
|
try:
|
|
raw = json.loads(str(composition_config))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid composition_config JSON: {exc}") from exc
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("composition_config must be a JSON object")
|
|
entries = _list_from(raw.get("composition_entries"))
|
|
if not entries and raw.get("pool_names"):
|
|
entries = _composition_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))])
|
|
return {
|
|
"enabled": bool(raw.get("enabled")) and bool(entries),
|
|
"apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace",
|
|
"pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()],
|
|
"composition_entries": entries,
|
|
"summary": str(raw.get("summary") or ""),
|
|
}
|
|
|
|
|
|
def _composition_config_active(composition_config: dict[str, Any]) -> bool:
|
|
return bool(composition_config.get("enabled")) and bool(composition_config.get("composition_entries"))
|
|
|
|
|
|
def build_thematic_location_json(
|
|
enabled: bool = True,
|
|
combine_mode: str = "replace",
|
|
theme: str = "semi_public_affair",
|
|
custom_locations: str = "",
|
|
custom_compositions: str = "",
|
|
location_config: str | dict[str, Any] | None = "",
|
|
composition_config: str | dict[str, Any] | None = "",
|
|
) -> tuple[str, str, str]:
|
|
theme_data = THEMATIC_LOCATION_PRESETS.get(str(theme or ""), THEMATIC_LOCATION_PRESETS["semi_public_affair"])
|
|
location_lines = "\n".join(
|
|
f"{entry['slug']}: {entry['prompt']}"
|
|
for entry in theme_data.get("locations", [])
|
|
if isinstance(entry, dict) and entry.get("slug") and entry.get("prompt")
|
|
)
|
|
if custom_locations.strip():
|
|
location_lines = "\n".join(part for part in (location_lines, custom_locations.strip()) if part)
|
|
composition_lines = "\n".join(str(entry) for entry in theme_data.get("compositions", []) if str(entry).strip())
|
|
if custom_compositions.strip():
|
|
composition_lines = "\n".join(part for part in (composition_lines, custom_compositions.strip()) if part)
|
|
resolved_location_config = build_location_pool_json(
|
|
enabled=enabled,
|
|
combine_mode=combine_mode,
|
|
preset="custom_only",
|
|
custom_locations=location_lines,
|
|
location_config=location_config or "",
|
|
)
|
|
resolved_composition_config = build_composition_pool_json(
|
|
enabled=enabled,
|
|
combine_mode=combine_mode,
|
|
preset="custom_only",
|
|
custom_compositions=composition_lines,
|
|
composition_config=composition_config or "",
|
|
)
|
|
location_summary = json.loads(resolved_location_config).get("summary", "")
|
|
composition_summary = json.loads(resolved_composition_config).get("summary", "")
|
|
summary = f"{theme}; locations={location_summary}; compositions={composition_summary}"
|
|
return resolved_location_config, resolved_composition_config, summary
|
|
|
|
|
|
def _ethnicity_text_from_value(value: Any) -> str:
|
|
if isinstance(value, dict):
|
|
return str(value.get("ethnicity") or "").strip()
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return ""
|
|
if text.startswith("{"):
|
|
try:
|
|
raw = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return text
|
|
if isinstance(raw, dict):
|
|
return str(raw.get("ethnicity") or "").strip()
|
|
return text
|
|
|
|
|
|
def _is_valid_ethnicity_filter(value: Any) -> bool:
|
|
text = _ethnicity_text_from_value(value)
|
|
return text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text
|
|
|
|
|
|
def normalize_ethnicity_filter(value: Any, default: str = "any", allow_random: bool = False) -> str:
|
|
text = _ethnicity_text_from_value(value)
|
|
if text.lower() in CHARACTER_RANDOM_TOKENS:
|
|
return "random" if allow_random else default
|
|
return text if _is_valid_ethnicity_filter(text) else default
|
|
|
|
|
|
def build_ethnicity_list_json(
|
|
include_european: bool = False,
|
|
include_mediterranean_mena: bool = False,
|
|
include_latina: bool = False,
|
|
include_east_asian: bool = False,
|
|
include_southeast_asian: bool = False,
|
|
include_south_asian: bool = False,
|
|
include_black_african: bool = False,
|
|
include_indigenous: bool = False,
|
|
include_mixed: bool = False,
|
|
include_asian: bool = False,
|
|
include_white_asian: bool = False,
|
|
include_western_european: bool = False,
|
|
include_french_european: bool = False,
|
|
include_germanic_european: bool = False,
|
|
include_nordic_european: bool = False,
|
|
include_celtic_european: bool = False,
|
|
include_slavic_european: bool = False,
|
|
include_baltic_european: bool = False,
|
|
include_alpine_european: bool = False,
|
|
include_balkan_european: bool = False,
|
|
include_greek_mediterranean: bool = False,
|
|
include_italian_mediterranean: bool = False,
|
|
include_iberian_mediterranean: bool = False,
|
|
strict_excludes: bool = True,
|
|
) -> dict[str, str]:
|
|
include_flags = {
|
|
"european": include_european,
|
|
"mediterranean_mena": include_mediterranean_mena,
|
|
"latina": include_latina,
|
|
"east_asian": include_east_asian,
|
|
"southeast_asian": include_southeast_asian,
|
|
"south_asian": include_south_asian,
|
|
"black_african": include_black_african,
|
|
"indigenous": include_indigenous,
|
|
"mixed": include_mixed,
|
|
"asian": include_asian,
|
|
"white_asian": include_white_asian,
|
|
"western_european": include_western_european,
|
|
"french_european": include_french_european,
|
|
"germanic_european": include_germanic_european,
|
|
"nordic_european": include_nordic_european,
|
|
"celtic_european": include_celtic_european,
|
|
"slavic_european": include_slavic_european,
|
|
"baltic_european": include_baltic_european,
|
|
"alpine_european": include_alpine_european,
|
|
"balkan_european": include_balkan_european,
|
|
"greek_mediterranean": include_greek_mediterranean,
|
|
"italian_mediterranean": include_italian_mediterranean,
|
|
"iberian_mediterranean": include_iberian_mediterranean,
|
|
}
|
|
selected = [key for key in ETHNICITY_LIST_KEYS if include_flags.get(key)]
|
|
if not selected or set(selected) == set(ETHNICITY_LIST_KEYS):
|
|
ethnicity = "any"
|
|
else:
|
|
tokens = list(selected)
|
|
if strict_excludes:
|
|
protected: set[str] = set()
|
|
if "asian" in selected:
|
|
protected.update(("east_asian", "southeast_asian", "south_asian"))
|
|
if "white_asian" in selected:
|
|
protected.update(("european", "east_asian", "southeast_asian", "south_asian", "mixed"))
|
|
if any(key in selected for key in EUROPEAN_REGIONAL_LIST_KEYS):
|
|
protected.add("european")
|
|
if any(key in selected for key in MEDITERRANEAN_REGIONAL_LIST_KEYS):
|
|
protected.add("mediterranean_mena")
|
|
if "mixed" in selected:
|
|
protected.update(ETHNICITY_BASE_LIST_KEYS)
|
|
tokens.extend(
|
|
f"exclude_{key}"
|
|
for key in ETHNICITY_BASE_LIST_KEYS
|
|
if key not in selected and key not in protected
|
|
)
|
|
ethnicity = "+".join(tokens)
|
|
filter_config = {
|
|
"ethnicity": ethnicity,
|
|
"ethnicity_includes": selected,
|
|
}
|
|
summary = "any ethnicity" if ethnicity == "any" else "ethnicity list: " + ", ".join(selected)
|
|
return {
|
|
"ethnicity": ethnicity,
|
|
"filter_config": json.dumps(filter_config, ensure_ascii=True, sort_keys=True),
|
|
"summary": summary,
|
|
}
|
|
|
|
|
|
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,
|
|
"include_plus_size": True,
|
|
"include_black_african": True,
|
|
}
|
|
if not filter_config:
|
|
return defaults
|
|
if isinstance(filter_config, dict):
|
|
raw = filter_config
|
|
else:
|
|
text = str(filter_config).strip()
|
|
if not text.startswith("{"):
|
|
raw = {"ethnicity": text}
|
|
else:
|
|
try:
|
|
raw = json.loads(text)
|
|
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"] = normalize_ethnicity_filter(parsed.get("ethnicity"), "any")
|
|
parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell", "random") else "curvy"
|
|
parsed["include_plus_size"] = bool(parsed.get("include_plus_size"))
|
|
parsed["include_black_african"] = bool(parsed.get("include_black_african"))
|
|
parsed["no_plus_women"] = bool(parsed.get("no_plus_women"))
|
|
parsed["no_black"] = bool(parsed.get("no_black"))
|
|
return parsed
|
|
|
|
|
|
def _normalize_hardcore_position_family(value: Any, default: str = "any") -> str:
|
|
text = str(value or default).strip()
|
|
return text if text in HARDCORE_POSITION_FAMILY_CHOICES else default
|
|
|
|
|
|
def _normalize_hardcore_position_values(values: Any) -> list[str]:
|
|
raw_values = _list_from(values)
|
|
selected: list[str] = []
|
|
for value in raw_values:
|
|
text = str(value or "").strip()
|
|
if not text or text == "any":
|
|
continue
|
|
normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
|
if normalized in HARDCORE_POSITION_KEY_CHOICES and normalized not in selected:
|
|
selected.append(normalized)
|
|
return selected
|
|
|
|
|
|
def _empty_hardcore_position_config() -> dict[str, Any]:
|
|
return {
|
|
"config_type": "hardcore_position",
|
|
"enabled": False,
|
|
"family": "any",
|
|
"positions": [],
|
|
"require_position": False,
|
|
"allow_toys": True,
|
|
"allow_double": True,
|
|
"allow_penetration": True,
|
|
"allow_foreplay": True,
|
|
"allow_interaction": True,
|
|
"allow_manual": True,
|
|
"allow_oral": True,
|
|
"allow_outercourse": True,
|
|
"allow_anal": True,
|
|
"allow_climax": True,
|
|
}
|
|
|
|
|
|
def _parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
if not value:
|
|
return _empty_hardcore_position_config()
|
|
if isinstance(value, dict):
|
|
raw = value
|
|
else:
|
|
try:
|
|
raw = json.loads(str(value))
|
|
except json.JSONDecodeError:
|
|
return _empty_hardcore_position_config()
|
|
if not isinstance(raw, dict):
|
|
return _empty_hardcore_position_config()
|
|
parsed = {**_empty_hardcore_position_config(), **raw}
|
|
parsed["enabled"] = bool(parsed.get("enabled", True))
|
|
parsed["family"] = _normalize_hardcore_position_family(parsed.get("family"))
|
|
parsed["positions"] = _normalize_hardcore_position_values(parsed.get("positions"))
|
|
parsed["require_position"] = not _is_false(parsed.get("require_position", False))
|
|
for key in (
|
|
"allow_toys",
|
|
"allow_double",
|
|
"allow_penetration",
|
|
"allow_foreplay",
|
|
"allow_interaction",
|
|
"allow_manual",
|
|
"allow_oral",
|
|
"allow_outercourse",
|
|
"allow_anal",
|
|
"allow_climax",
|
|
):
|
|
parsed[key] = not _is_false(parsed.get(key, True))
|
|
return parsed
|
|
|
|
|
|
def _hardcore_position_summary(config: dict[str, Any]) -> str:
|
|
if not config.get("enabled"):
|
|
return "hardcore position unrestricted"
|
|
parts = [f"family={config.get('family', 'any')}"]
|
|
positions = config.get("positions") or []
|
|
if positions:
|
|
parts.append("positions=" + ",".join(positions))
|
|
elif config.get("require_position"):
|
|
parts.append("position_templates=required")
|
|
disabled = [
|
|
label
|
|
for key, label in (
|
|
("allow_toys", "toys"),
|
|
("allow_double", "double"),
|
|
("allow_penetration", "penetration"),
|
|
("allow_foreplay", "foreplay"),
|
|
("allow_interaction", "interaction"),
|
|
("allow_manual", "manual"),
|
|
("allow_oral", "oral"),
|
|
("allow_outercourse", "outercourse"),
|
|
("allow_anal", "anal"),
|
|
("allow_climax", "climax"),
|
|
)
|
|
if not config.get(key, True)
|
|
]
|
|
if disabled:
|
|
parts.append("blocked=" + ",".join(disabled))
|
|
return "; ".join(parts)
|
|
|
|
|
|
def build_hardcore_position_pool_json(
|
|
hardcore_position_config: str | dict[str, Any] | None = "",
|
|
combine_mode: str = "replace",
|
|
family: str = "any",
|
|
selected_positions: list[str] | tuple[str, ...] | str | None = None,
|
|
) -> str:
|
|
base = _parse_hardcore_position_config(hardcore_position_config)
|
|
if combine_mode == "replace":
|
|
base = {**_empty_hardcore_position_config(), "enabled": True}
|
|
else:
|
|
base["enabled"] = True
|
|
base["family"] = _normalize_hardcore_position_family(family, base.get("family", "any"))
|
|
selected = _normalize_hardcore_position_values(selected_positions)
|
|
if combine_mode == "add":
|
|
existing = list(base.get("positions") or [])
|
|
for value in selected:
|
|
if value not in existing:
|
|
existing.append(value)
|
|
base["positions"] = existing
|
|
else:
|
|
base["positions"] = selected
|
|
base["require_position"] = bool(base.get("require_position")) or bool(base["positions"]) or base["family"] != "any"
|
|
base["summary"] = _hardcore_position_summary(base)
|
|
return json.dumps(base, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def build_hardcore_action_filter_json(
|
|
hardcore_position_config: str | dict[str, Any] | None = "",
|
|
focus: str = "keep_pool",
|
|
allow_toys: bool = False,
|
|
allow_double: bool = False,
|
|
allow_penetration: bool = True,
|
|
allow_foreplay: bool = True,
|
|
allow_interaction: bool = True,
|
|
allow_manual: bool = True,
|
|
allow_oral: bool = True,
|
|
allow_outercourse: bool = True,
|
|
allow_anal: bool = True,
|
|
allow_climax: bool = True,
|
|
) -> str:
|
|
config = _parse_hardcore_position_config(hardcore_position_config)
|
|
config["enabled"] = True
|
|
focus = str(focus or "keep_pool").strip()
|
|
focus_family = {
|
|
"penetration_only": "penetrative",
|
|
"foreplay_only": "foreplay",
|
|
"interaction_only": "interaction",
|
|
"manual_only": "manual",
|
|
"oral_only": "oral",
|
|
"outercourse_only": "outercourse",
|
|
"anal_only": "anal",
|
|
"climax_only": "climax",
|
|
"threesome_only": "threesome",
|
|
"group_only": "group",
|
|
}.get(focus)
|
|
if focus_family:
|
|
config["family"] = focus_family
|
|
config["allow_toys"] = bool(allow_toys)
|
|
config["allow_double"] = bool(allow_double)
|
|
config["allow_penetration"] = bool(allow_penetration)
|
|
config["allow_foreplay"] = bool(allow_foreplay)
|
|
config["allow_interaction"] = bool(allow_interaction)
|
|
config["allow_manual"] = bool(allow_manual)
|
|
config["allow_oral"] = bool(allow_oral)
|
|
config["allow_outercourse"] = bool(allow_outercourse)
|
|
config["allow_anal"] = bool(allow_anal)
|
|
config["allow_climax"] = bool(allow_climax)
|
|
|
|
if not focus_family and config["family"] != "any":
|
|
enabled_action_families = {
|
|
family
|
|
for enabled, family in (
|
|
(config["allow_penetration"], "penetrative"),
|
|
(config["allow_foreplay"], "foreplay"),
|
|
(config["allow_interaction"], "interaction"),
|
|
(config["allow_manual"], "manual"),
|
|
(config["allow_oral"], "oral"),
|
|
(config["allow_outercourse"], "outercourse"),
|
|
(config["allow_anal"], "anal"),
|
|
(config["allow_climax"], "climax"),
|
|
)
|
|
if enabled
|
|
}
|
|
if config["family"] in enabled_action_families and len(enabled_action_families) > 1:
|
|
config["family"] = "any"
|
|
|
|
if focus == "foreplay_only":
|
|
config["allow_foreplay"] = True
|
|
config["allow_interaction"] = True
|
|
elif focus == "interaction_only":
|
|
config["allow_interaction"] = True
|
|
config["allow_foreplay"] = True
|
|
elif focus == "manual_only":
|
|
config["allow_manual"] = True
|
|
elif focus == "oral_only":
|
|
config["allow_oral"] = True
|
|
config["allow_penetration"] = False
|
|
elif focus == "outercourse_only":
|
|
config["allow_outercourse"] = True
|
|
config["allow_oral"] = False
|
|
config["allow_penetration"] = False
|
|
elif focus == "anal_only":
|
|
config["allow_anal"] = True
|
|
config["allow_penetration"] = True
|
|
elif focus == "climax_only":
|
|
config["allow_climax"] = True
|
|
config["summary"] = _hardcore_position_summary(config)
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _hardcore_position_config_active(config: dict[str, Any]) -> bool:
|
|
return bool(config.get("enabled"))
|
|
|
|
|
|
def _hardcore_position_template_required(config: dict[str, Any]) -> bool:
|
|
if not _hardcore_position_config_active(config):
|
|
return False
|
|
return bool(config.get("require_position")) or bool(config.get("positions")) or _normalize_hardcore_position_family(config.get("family")) != "any"
|
|
|
|
|
|
def _is_hardcore_sexual_category(category: dict[str, Any]) -> bool:
|
|
return str(category.get("slug") or "").strip() == "hardcore_sexual_poses" or str(category.get("name") or "").strip().lower() == "hardcore sexual poses"
|
|
|
|
|
|
def _hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]:
|
|
family = _normalize_hardcore_position_family(config.get("family"))
|
|
allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]))
|
|
if not config.get("allow_penetration", True):
|
|
allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"})
|
|
if not config.get("allow_foreplay", True):
|
|
allowed.discard("foreplay_teasing")
|
|
if not config.get("allow_interaction", True):
|
|
allowed.difference_update(
|
|
{
|
|
"foreplay_teasing",
|
|
"body_worship_touching",
|
|
"clothing_position_transitions",
|
|
"dominant_guidance",
|
|
"camera_performance",
|
|
"group_coordination",
|
|
"aftercare_cleanup",
|
|
}
|
|
)
|
|
if not config.get("allow_manual", True):
|
|
allowed.discard("manual_stimulation")
|
|
if not config.get("allow_oral", True):
|
|
allowed.discard("oral_sex")
|
|
if not config.get("allow_outercourse", True):
|
|
allowed.discard("outercourse_sex")
|
|
if not config.get("allow_anal", True):
|
|
allowed.discard("anal_double_penetration")
|
|
if not config.get("allow_climax", True):
|
|
allowed.discard("cumshot_climax")
|
|
if not config.get("allow_double", True) and family == "anal":
|
|
allowed.add("anal_double_penetration")
|
|
return allowed or set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])
|
|
|
|
|
|
def _filter_hardcore_categories_for_position(
|
|
categories: list[dict[str, Any]],
|
|
config: dict[str, Any],
|
|
women_count: int,
|
|
men_count: int,
|
|
) -> list[dict[str, Any]]:
|
|
if not _hardcore_position_config_active(config):
|
|
return categories
|
|
allowed = _hardcore_allowed_subcategory_slugs(config)
|
|
filtered_categories: list[dict[str, Any]] = []
|
|
for category in categories:
|
|
if not _is_hardcore_sexual_category(category):
|
|
filtered_categories.append(category)
|
|
continue
|
|
category_copy = dict(category)
|
|
subcategories = [
|
|
subcategory
|
|
for subcategory in category.get("subcategories", [])
|
|
if str(subcategory.get("slug") or "") in allowed and _compatible_entry(subcategory, women_count, men_count)
|
|
and _hardcore_subcategory_supports_positions(subcategory, config)
|
|
]
|
|
if subcategories:
|
|
category_copy["subcategories"] = subcategories
|
|
filtered_categories.append(category_copy)
|
|
return filtered_categories
|
|
|
|
|
|
def _hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str, Any]) -> bool:
|
|
text = str(text or "").lower()
|
|
axis_name = str(axis_name or "").lower()
|
|
if not config.get("allow_toys", True) and any(term in text for term in ("toy", "dildo", "strap-on", "strap on")):
|
|
return True
|
|
if not config.get("allow_double", True) and (
|
|
axis_name == "double_act"
|
|
or any(term in text for term in ("double penetration", "double-penetration", "front-and-back", "front and back", "second penetration", "both sides", "two partners penetrating", "multiple penetrations"))
|
|
):
|
|
return True
|
|
if not config.get("allow_anal", True) and (
|
|
axis_name == "anal_act"
|
|
or any(term in text for term in (" anal", "anal sex", "anal penetration", "anus", "rear-entry anal", "penis entering ass", "thrusts into her ass", "thrusts into his ass"))
|
|
):
|
|
return True
|
|
if not config.get("allow_oral", True) and (
|
|
axis_name in ("oral_act", "oral_detail")
|
|
or any(term in text for term in ("oral sex", "mouth on genitals", "mouth on pussy", "blowjob", "cunnilingus", "tongue on pussy", "deepthroat", "fellatio"))
|
|
):
|
|
return True
|
|
if not config.get("allow_outercourse", True) and (
|
|
axis_name in ("outer_act", "contact_detail", "texture_detail")
|
|
or any(term in text for term in ("boobjob", "titjob", "breast sex", "breast-sex", "testicle", "balls", "penis licking", "penis-licking", "footjob", "soles", "toes"))
|
|
):
|
|
return True
|
|
if not config.get("allow_penetration", True) and (
|
|
axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail")
|
|
or any(term in text for term in ("penetration", "penetrative", "thrust", "penis entering", "vaginal sex", "anal sex"))
|
|
):
|
|
return True
|
|
if not config.get("allow_foreplay", True) and (
|
|
axis_name in ("tease_act", "touch_detail", "clothing_detail", "foreplay_detail", "face_detail", "body_contact", "mood_detail")
|
|
or any(
|
|
term in text
|
|
for term in (
|
|
"kiss",
|
|
"kissing",
|
|
"mouth-to-mouth",
|
|
"caress",
|
|
"caressing",
|
|
"stroking skin",
|
|
"hands roaming",
|
|
"touching breasts",
|
|
"cupping breasts",
|
|
"hand on the cheek",
|
|
"fingers under the chin",
|
|
"undressing",
|
|
"removing clothing",
|
|
"removing clothes",
|
|
"pulling clothing",
|
|
"sliding straps",
|
|
"unbuttoning",
|
|
)
|
|
)
|
|
):
|
|
return True
|
|
if not config.get("allow_interaction", True) and (
|
|
axis_name
|
|
in (
|
|
"tease_act",
|
|
"touch_detail",
|
|
"clothing_detail",
|
|
"foreplay_detail",
|
|
"face_detail",
|
|
"body_contact",
|
|
"mood_detail",
|
|
"worship_act",
|
|
"transition_act",
|
|
"control_act",
|
|
"performance_act",
|
|
"coordination_act",
|
|
"aftercare_act",
|
|
"cleanup_detail",
|
|
)
|
|
or any(
|
|
term in text
|
|
for term in (
|
|
"kiss",
|
|
"kissing",
|
|
"caress",
|
|
"body worship",
|
|
"nipple",
|
|
"ass grab",
|
|
"thigh",
|
|
"hair holding",
|
|
"wrists",
|
|
"dirty talk",
|
|
"whispering",
|
|
"undressing",
|
|
"position transition",
|
|
"guided",
|
|
"camera",
|
|
"watching",
|
|
"aftercare",
|
|
"cleanup",
|
|
"wiping",
|
|
)
|
|
)
|
|
):
|
|
return True
|
|
if not config.get("allow_manual", True) and (
|
|
axis_name in ("manual_act", "manual_detail")
|
|
or any(
|
|
term in text
|
|
for term in (
|
|
"fingering",
|
|
"fingers inside",
|
|
"clit",
|
|
"clitoris",
|
|
"manual stimulation",
|
|
"mutual masturbation",
|
|
"masturbating together",
|
|
"fingers on pussy",
|
|
"fingers on clit",
|
|
)
|
|
)
|
|
):
|
|
return True
|
|
if not config.get("allow_climax", True) and (
|
|
axis_name in ("climax_act", "climax_hint", "climax_detail", "fluid_detail", "fluid_location")
|
|
or any(term in text for term in ("climax", "cum", "semen", "ejaculat", "creampie", "post-orgasm", "post-penetration"))
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _hardcore_position_entry_matches(entry: Any, config: dict[str, Any]) -> bool:
|
|
positions = config.get("positions") or []
|
|
if not positions:
|
|
return True
|
|
text = _entry_text(entry).lower()
|
|
for position in positions:
|
|
if any(term in text for term in HARDCORE_POSITION_KEY_MATCHES.get(position, ())):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> bool:
|
|
selected = set(config.get("positions") or [])
|
|
if not selected:
|
|
return False
|
|
text = _entry_text(entry).lower()
|
|
matched = {
|
|
position
|
|
for position, terms in HARDCORE_POSITION_KEY_MATCHES.items()
|
|
if any(term in text for term in terms)
|
|
}
|
|
return bool(matched) and not bool(matched & selected)
|
|
|
|
|
|
def _hardcore_subcategory_supports_positions(subcategory: dict[str, Any], config: dict[str, Any]) -> bool:
|
|
if not _hardcore_position_template_required(config):
|
|
return True
|
|
axes = subcategory.get("item_axes")
|
|
if not isinstance(axes, dict):
|
|
return True
|
|
for axis_name, values in axes.items():
|
|
if str(axis_name) in HARDCORE_POSITION_AXIS_KEYS and any(
|
|
_hardcore_position_entry_matches(value, config)
|
|
for value in _list_from(values)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, Any]) -> list[Any]:
|
|
if not _hardcore_position_config_active(config):
|
|
return values
|
|
filtered = [
|
|
value
|
|
for value in values
|
|
if not _hardcore_text_blocked_by_action(_entry_text(value), axis_name, config)
|
|
and not (axis_name not in HARDCORE_POSITION_AXIS_KEYS and _hardcore_position_entry_conflicts(value, config))
|
|
and (axis_name not in HARDCORE_POSITION_AXIS_KEYS or _hardcore_position_entry_matches(value, config))
|
|
]
|
|
return filtered or values
|
|
|
|
|
|
def _filter_hardcore_templates(templates: list[Any], config: dict[str, Any]) -> list[Any]:
|
|
if not _hardcore_position_config_active(config):
|
|
return templates
|
|
filtered: list[Any] = []
|
|
for template in templates:
|
|
text = _entry_text(template)
|
|
fields = {key for _, key, _, _ in Formatter().parse(text) if key}
|
|
blocked = _hardcore_position_template_required(config) and not bool(fields & HARDCORE_POSITION_AXIS_KEYS)
|
|
blocked = blocked or any(_hardcore_text_blocked_by_action(text, field, config) for field in fields | {""})
|
|
if not blocked:
|
|
filtered.append(template)
|
|
return filtered or templates
|
|
|
|
|
|
def _apply_hardcore_position_config_to_subcategory(
|
|
subcategory: dict[str, Any],
|
|
config: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
if not _hardcore_position_config_active(config):
|
|
return subcategory
|
|
subcategory_copy = dict(subcategory)
|
|
if "item_templates" in subcategory_copy:
|
|
subcategory_copy["item_templates"] = _filter_hardcore_templates(_list_from(subcategory_copy["item_templates"]), config)
|
|
raw_axes = subcategory_copy.get("item_axes")
|
|
if isinstance(raw_axes, dict):
|
|
axes = {}
|
|
for axis_name, values in raw_axes.items():
|
|
axes[axis_name] = _filter_hardcore_axis(str(axis_name), _list_from(values), config)
|
|
subcategory_copy["item_axes"] = axes
|
|
subcategory_copy["hardcore_position_config"] = config
|
|
return subcategory_copy
|
|
|
|
|
|
def _ratio_or_none(value: float) -> float | None:
|
|
try:
|
|
ratio = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if ratio < 0:
|
|
return None
|
|
return max(0.0, min(1.0, ratio))
|
|
|
|
|
|
def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(min_value, min(max_value, number))
|
|
|
|
|
|
def build_seed_config_json(
|
|
category_seed: int = -1,
|
|
subcategory_seed: int = -1,
|
|
content_seed: int = -1,
|
|
person_seed: int = -1,
|
|
scene_seed: int = -1,
|
|
pose_seed: int = -1,
|
|
role_seed: int = -1,
|
|
expression_seed: int = -1,
|
|
composition_seed: int = -1,
|
|
category_seed_mode: str = "auto",
|
|
subcategory_seed_mode: str = "auto",
|
|
content_seed_mode: str = "auto",
|
|
person_seed_mode: str = "auto",
|
|
scene_seed_mode: str = "auto",
|
|
pose_seed_mode: str = "auto",
|
|
role_seed_mode: str = "auto",
|
|
expression_seed_mode: str = "auto",
|
|
composition_seed_mode: str = "auto",
|
|
) -> str:
|
|
rng = random.SystemRandom()
|
|
|
|
def axis_seed(value: int, mode: str) -> int:
|
|
mode = mode if mode in SEED_MODE_CHOICES else "auto"
|
|
if mode == "auto":
|
|
return int(value)
|
|
if mode == "random":
|
|
return rng.randint(0, 0xFFFFFFFF)
|
|
if mode == "fixed":
|
|
return max(0, int(value))
|
|
return -1
|
|
|
|
return json.dumps(
|
|
{
|
|
"category_seed": axis_seed(category_seed, category_seed_mode),
|
|
"subcategory_seed": axis_seed(subcategory_seed, subcategory_seed_mode),
|
|
"content_seed": axis_seed(content_seed, content_seed_mode),
|
|
"person_seed": axis_seed(person_seed, person_seed_mode),
|
|
"scene_seed": axis_seed(scene_seed, scene_seed_mode),
|
|
"pose_seed": axis_seed(pose_seed, pose_seed_mode),
|
|
"role_seed": axis_seed(role_seed, role_seed_mode),
|
|
"expression_seed": axis_seed(expression_seed, expression_seed_mode),
|
|
"composition_seed": axis_seed(composition_seed, composition_seed_mode),
|
|
},
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def build_seed_lock_config_json(
|
|
base_seed: int = 20260614,
|
|
reroll_axis: str = "none",
|
|
reroll_seed: int = -1,
|
|
) -> str:
|
|
base_seed = int(base_seed)
|
|
reroll_seed = int(reroll_seed)
|
|
reroll_groups = {
|
|
"none": (),
|
|
"category": ("category",),
|
|
"subcategory": ("subcategory",),
|
|
"content": ("content",),
|
|
"person": ("person",),
|
|
"scene": ("scene",),
|
|
"pose": ("pose", "role"),
|
|
"role": ("role",),
|
|
"expression": ("expression",),
|
|
"composition": ("composition",),
|
|
"content_pose": ("content", "pose", "role"),
|
|
"scene_pose": ("scene", "pose", "role"),
|
|
}
|
|
reroll = set(reroll_groups.get(str(reroll_axis or "none"), ()))
|
|
config: dict[str, int] = {}
|
|
for axis in SEED_LOCK_AXES:
|
|
config[f"{axis}_seed"] = reroll_seed if axis in reroll else base_seed
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _parse_seed_config(seed_config: str | dict[str, Any] | None) -> dict[str, int]:
|
|
if not seed_config:
|
|
return {}
|
|
if isinstance(seed_config, dict):
|
|
raw = seed_config
|
|
else:
|
|
try:
|
|
raw = json.loads(str(seed_config))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid seed_config JSON: {exc}") from exc
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("seed_config must be a JSON object")
|
|
parsed: dict[str, int] = {}
|
|
for key, value in raw.items():
|
|
try:
|
|
parsed[str(key)] = int(value)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return parsed
|
|
|
|
|
|
def _configured_axis_seed(seed_config: dict[str, int], axis: str) -> int | None:
|
|
for key in SEED_AXIS_ALIASES.get(axis, (axis,)):
|
|
value = seed_config.get(key)
|
|
if value is not None and value >= 0:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _axis_rng(seed_config: dict[str, int], axis: str, base_seed: int, row_number: int) -> random.Random:
|
|
configured = _configured_axis_seed(seed_config, axis)
|
|
salt = SEED_AXIS_SALTS.get(axis, 0)
|
|
if configured is None:
|
|
return random.Random(_row_seed(base_seed, row_number, salt))
|
|
return random.Random(_row_seed(configured, row_number, salt))
|
|
|
|
|
|
def _is_pose_content_category(category: dict[str, Any], subcategory: dict[str, Any]) -> bool:
|
|
haystack = " ".join(
|
|
str(value)
|
|
for value in (
|
|
category.get("name", ""),
|
|
category.get("slug", ""),
|
|
category.get("item_label", ""),
|
|
subcategory.get("name", ""),
|
|
subcategory.get("slug", ""),
|
|
subcategory.get("item_label", ""),
|
|
)
|
|
).lower()
|
|
return "pose" in haystack or "sex" in haystack
|
|
|
|
|
|
def _format(template: str, context: dict[str, Any]) -> str:
|
|
fields = {key for _, key, _, _ in Formatter().parse(template) if key}
|
|
safe_context = SafeFormatDict({key: str(value) for key, value in context.items()})
|
|
for field in fields:
|
|
safe_context.setdefault(field, "{" + field + "}")
|
|
return template.format_map(safe_context)
|
|
|
|
|
|
def _clean_prompt_punctuation(text: str) -> str:
|
|
text = re.sub(r"\s+", " ", str(text or "")).strip()
|
|
text = re.sub(r"\s+([,.;:])", r"\1", text)
|
|
text = re.sub(r"(?:,\s*){2,}", ", ", text)
|
|
text = re.sub(r"\.\s*\.", ".", text)
|
|
text = re.sub(r":\s*\.", ".", text)
|
|
return text.strip()
|
|
|
|
|
|
def _strip_expression_text(text: str, expression: Any = "") -> str:
|
|
text = str(text or "")
|
|
if not text:
|
|
return ""
|
|
text = re.sub(r"\s*Facial expressions?:\s*[^.]*\.\s*", " ", text, flags=re.IGNORECASE)
|
|
text = re.sub(r",\s*one with [^,]+ and the other with [^,]+(?=,)", "", text, flags=re.IGNORECASE)
|
|
text = re.sub(r",\s*a lively mix of expressions from [^,]+(?=,)", "", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\s+with\s+(?:an?|the)\s+[^,]*expression(?=,)", "", text, flags=re.IGNORECASE)
|
|
expression_text = str(expression or "").strip()
|
|
if expression_text:
|
|
for part in [piece.strip() for piece in expression_text.split(";") if piece.strip()]:
|
|
escaped = re.escape(part)
|
|
text = re.sub(rf",\s*{escaped}(?=,)", "", text, flags=re.IGNORECASE)
|
|
text = re.sub(rf"\s+with\s+(?:an?|the)?\s*{escaped}", "", text, flags=re.IGNORECASE)
|
|
return _clean_prompt_punctuation(text)
|
|
|
|
|
|
def _disable_row_expression(row: dict[str, Any], source: str = "disabled") -> dict[str, Any]:
|
|
previous_expression = row.get("expression", "")
|
|
row["prompt"] = _strip_expression_text(row.get("prompt", ""), previous_expression)
|
|
row["caption"] = _strip_expression_text(row.get("caption", ""), previous_expression)
|
|
row["expression"] = ""
|
|
row["shared_expression"] = ""
|
|
row["character_expressions"] = []
|
|
row["character_expression_text"] = ""
|
|
row["expression_enabled"] = False
|
|
row["expression_disabled"] = True
|
|
row["expression_intensity"] = None
|
|
row["expression_intensity_source"] = source
|
|
return row
|
|
|
|
|
|
def _labeled_expression_sentence(label: str, expression: Any) -> str:
|
|
expression = str(expression or "").strip()
|
|
if not expression:
|
|
return ""
|
|
return f"{label}: {expression}. "
|
|
|
|
|
|
def _prepend_trigger(prompt: str, trigger: str, enabled: bool) -> str:
|
|
trigger = trigger.strip()
|
|
if not enabled or not trigger:
|
|
return prompt
|
|
if prompt.lower().startswith(trigger.lower()):
|
|
return prompt
|
|
return f"{trigger}, {prompt}"
|
|
|
|
|
|
def _combined_negative(base: str, extra: str) -> str:
|
|
parts = [part.strip() for part in (base, extra) if part and part.strip()]
|
|
return ", ".join(parts)
|
|
|
|
|
|
def camera_mode_choices() -> list[str]:
|
|
return list(CAMERA_MODE_PROMPTS)
|
|
|
|
|
|
def ethnicity_choices() -> list[str]:
|
|
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_woman_body_choices() -> list[str]:
|
|
return list(CHARACTER_WOMAN_BODY_CHOICES)
|
|
|
|
|
|
def character_man_body_choices() -> list[str]:
|
|
return list(CHARACTER_MAN_BODY_CHOICES)
|
|
|
|
|
|
def character_descriptor_detail_choices() -> list[str]:
|
|
return list(CHARACTER_DESCRIPTOR_DETAIL_CHOICES)
|
|
|
|
|
|
def character_presence_choices() -> list[str]:
|
|
return list(CHARACTER_PRESENCE_CHOICES)
|
|
|
|
|
|
def character_hair_color_choices() -> list[str]:
|
|
return list(CHARACTER_HAIR_COLOR_CHOICES)
|
|
|
|
|
|
def character_hair_length_choices() -> list[str]:
|
|
return list(CHARACTER_HAIR_LENGTH_CHOICES)
|
|
|
|
|
|
def character_hair_style_choices() -> list[str]:
|
|
return list(CHARACTER_HAIR_STYLE_CHOICES)
|
|
|
|
|
|
def character_eye_color_choices() -> list[str]:
|
|
return list(CHARACTER_EYE_COLOR_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]:
|
|
return list(CAMERA_DETAIL_CHOICES)
|
|
|
|
|
|
def hardcore_detail_density_choices() -> list[str]:
|
|
return list(HARDCORE_DETAIL_DENSITY_CHOICES)
|
|
|
|
|
|
def hardcore_position_family_choices() -> list[str]:
|
|
return list(HARDCORE_POSITION_FAMILY_CHOICES)
|
|
|
|
|
|
def hardcore_position_focus_choices() -> list[str]:
|
|
return list(HARDCORE_POSITION_FOCUS_CHOICES)
|
|
|
|
|
|
def hardcore_position_key_choices() -> list[str]:
|
|
return list(HARDCORE_POSITION_KEY_CHOICES)
|
|
|
|
|
|
def character_softcore_outfit_source_choices() -> list[str]:
|
|
return [
|
|
"no_change",
|
|
"social_tease",
|
|
"lingerie_tease",
|
|
"implied_nude",
|
|
"explicit_tease",
|
|
"explicit_nude",
|
|
"partner_woman",
|
|
"partner_man",
|
|
"custom",
|
|
]
|
|
|
|
|
|
def character_hardcore_clothing_state_choices() -> list[str]:
|
|
return [
|
|
"no_change",
|
|
"fully_nude",
|
|
"partly_exposed",
|
|
"same_outfit",
|
|
"partially_removed",
|
|
"custom",
|
|
]
|
|
|
|
|
|
def camera_orbit_framing_choices() -> list[str]:
|
|
return list(CAMERA_ORBIT_FRAMING_CHOICES)
|
|
|
|
|
|
def camera_orbit_focus_choices() -> list[str]:
|
|
return list(CAMERA_ORBIT_FOCUS_CHOICES)
|
|
|
|
|
|
def camera_shot_choices() -> list[str]:
|
|
return list(CAMERA_SHOT_PROMPTS)
|
|
|
|
|
|
def camera_angle_choices() -> list[str]:
|
|
return list(CAMERA_ANGLE_PROMPTS)
|
|
|
|
|
|
def camera_lens_choices() -> list[str]:
|
|
return list(CAMERA_LENS_PROMPTS)
|
|
|
|
|
|
def camera_distance_choices() -> list[str]:
|
|
return list(CAMERA_DISTANCE_PROMPTS)
|
|
|
|
|
|
def camera_orientation_choices() -> list[str]:
|
|
return list(CAMERA_ORIENTATION_PROMPTS)
|
|
|
|
|
|
def camera_phone_choices() -> list[str]:
|
|
return list(CAMERA_PHONE_PROMPTS)
|
|
|
|
|
|
def camera_priority_choices() -> list[str]:
|
|
return list(CAMERA_PRIORITY_PROMPTS)
|
|
|
|
|
|
def build_camera_config_json(
|
|
camera_mode: str = "standard",
|
|
shot_size: str = "auto",
|
|
angle: str = "auto",
|
|
lens: str = "auto",
|
|
distance: str = "auto",
|
|
orientation: str = "auto",
|
|
phone_visibility: str = "auto",
|
|
priority: str = "strong",
|
|
camera_detail: str = "compact",
|
|
) -> str:
|
|
return json.dumps(
|
|
{
|
|
"camera_mode": camera_mode,
|
|
"shot_size": shot_size,
|
|
"angle": angle,
|
|
"lens": lens,
|
|
"distance": distance,
|
|
"orientation": orientation,
|
|
"phone_visibility": phone_visibility,
|
|
"priority": priority,
|
|
"camera_detail": camera_detail,
|
|
},
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def _camera_orbit_direction(horizontal_angle: Any) -> str:
|
|
h_angle = int(float(horizontal_angle or 0)) % 360
|
|
if h_angle < 22.5 or h_angle >= 337.5:
|
|
return "front view"
|
|
if h_angle < 67.5:
|
|
return "front-right quarter view"
|
|
if h_angle < 112.5:
|
|
return "right side view"
|
|
if h_angle < 157.5:
|
|
return "back-right quarter view"
|
|
if h_angle < 202.5:
|
|
return "back view"
|
|
if h_angle < 247.5:
|
|
return "back-left quarter view"
|
|
if h_angle < 292.5:
|
|
return "left side view"
|
|
return "front-left quarter view"
|
|
|
|
|
|
def _camera_orbit_elevation(vertical_angle: Any) -> str:
|
|
vertical = int(float(vertical_angle or 0))
|
|
if vertical < -15:
|
|
return "low-angle shot"
|
|
if vertical < 15:
|
|
return "eye-level shot"
|
|
if vertical < 45:
|
|
return "elevated shot"
|
|
return "high-angle shot"
|
|
|
|
|
|
def _camera_orbit_distance(zoom: Any, framing: str = "from_zoom") -> str:
|
|
framing = framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom"
|
|
framing_labels = {
|
|
"wide": "wide shot",
|
|
"medium": "medium shot",
|
|
"full_body": "full-body shot",
|
|
"three_quarter": "three-quarter body shot",
|
|
"close_up": "close-up",
|
|
"extreme_close_up": "extreme close-up",
|
|
}
|
|
if framing != "from_zoom":
|
|
return framing_labels[framing]
|
|
zoom_value = float(zoom or 0.0)
|
|
if zoom_value < 2:
|
|
return "wide shot"
|
|
if zoom_value < 6:
|
|
return "medium shot"
|
|
return "close-up"
|
|
|
|
|
|
def _camera_orbit_focus(subject_focus: str) -> str:
|
|
return {
|
|
"face": "face and expression centered",
|
|
"torso": "torso and hands centered",
|
|
"hips": "hips and lower body centered",
|
|
"full_body": "full body centered",
|
|
"action": "main action centered",
|
|
"contact_points": "body contact points centered",
|
|
"environment": "subject and room both readable",
|
|
}.get(str(subject_focus or "auto"), "")
|
|
|
|
|
|
def _camera_orbit_prompt(
|
|
horizontal_angle: Any,
|
|
vertical_angle: Any,
|
|
zoom: Any,
|
|
framing: str = "from_zoom",
|
|
subject_focus: str = "auto",
|
|
include_degrees: bool = True,
|
|
) -> tuple[str, dict[str, Any]]:
|
|
azimuth = max(0, min(359, int(float(horizontal_angle or 0))))
|
|
elevation = max(-90, min(90, int(float(vertical_angle or 0))))
|
|
zoom_value = max(0.0, min(10.0, float(zoom or 0.0)))
|
|
direction = _camera_orbit_direction(azimuth)
|
|
elevation_label = _camera_orbit_elevation(elevation)
|
|
distance_label = _camera_orbit_distance(zoom_value, framing)
|
|
focus_label = _camera_orbit_focus(subject_focus)
|
|
pieces = [direction, elevation_label, distance_label, focus_label]
|
|
prompt = ", ".join(piece for piece in pieces if piece)
|
|
if include_degrees:
|
|
prompt = f"{azimuth}-degree {prompt}"
|
|
return prompt, {
|
|
"orbit_azimuth": azimuth,
|
|
"orbit_elevation": elevation,
|
|
"orbit_zoom": zoom_value,
|
|
"orbit_direction": direction,
|
|
"orbit_elevation_label": elevation_label,
|
|
"orbit_distance_label": distance_label,
|
|
"orbit_framing": framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom",
|
|
"orbit_focus": subject_focus if subject_focus in CAMERA_ORBIT_FOCUS_CHOICES else "auto",
|
|
}
|
|
|
|
|
|
def build_camera_orbit_config_json(
|
|
enabled: bool = True,
|
|
camera_mode: str = "standard",
|
|
horizontal_angle: int = 0,
|
|
vertical_angle: int = 0,
|
|
zoom: float = 5.0,
|
|
framing: str = "from_zoom",
|
|
subject_focus: str = "auto",
|
|
lens: str = "auto",
|
|
orientation: str = "auto",
|
|
phone_visibility: str = "auto",
|
|
priority: str = "locked",
|
|
camera_detail: str = "compact",
|
|
include_degrees: bool = True,
|
|
) -> str:
|
|
orbit_prompt, orbit_metadata = _camera_orbit_prompt(
|
|
horizontal_angle,
|
|
vertical_angle,
|
|
zoom,
|
|
framing=framing,
|
|
subject_focus=subject_focus,
|
|
include_degrees=include_degrees,
|
|
)
|
|
config = {
|
|
"camera_mode": "disabled" if _is_false(enabled) else _choice(camera_mode, CAMERA_MODE_PROMPTS, "standard"),
|
|
"shot_size": "auto",
|
|
"angle": "auto",
|
|
"lens": _choice(lens, CAMERA_LENS_PROMPTS, "auto"),
|
|
"distance": "auto",
|
|
"orientation": _choice(orientation, CAMERA_ORIENTATION_PROMPTS, "auto"),
|
|
"phone_visibility": _choice(phone_visibility, CAMERA_PHONE_PROMPTS, "auto"),
|
|
"priority": _choice(priority, CAMERA_PRIORITY_PROMPTS, "locked"),
|
|
"camera_detail": camera_detail if camera_detail in CAMERA_DETAIL_CHOICES else "compact",
|
|
"camera_source": "orbit",
|
|
"custom_camera_prompt": orbit_prompt if not _is_false(enabled) else "",
|
|
**orbit_metadata,
|
|
}
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
QWEN_CAMERA_DIRECTIONS = {
|
|
"front-right quarter view": 45,
|
|
"right side view": 90,
|
|
"back-right quarter view": 135,
|
|
"back view": 180,
|
|
"back-left quarter view": 225,
|
|
"left side view": 270,
|
|
"front-left quarter view": 315,
|
|
"front view": 0,
|
|
}
|
|
QWEN_CAMERA_ELEVATIONS = {
|
|
"low-angle shot": -30,
|
|
"eye-level shot": 0,
|
|
"elevated shot": 30,
|
|
"high-angle shot": 60,
|
|
}
|
|
QWEN_CAMERA_ZOOMS = {
|
|
"wide shot": 0.0,
|
|
"medium shot": 5.0,
|
|
"close-up": 8.0,
|
|
}
|
|
QWEN_CAMERA_SCENE_CENTER_Y = 0.5
|
|
|
|
|
|
def _qwen_prompt_camera_values(qwen_prompt: Any) -> tuple[int, int, float]:
|
|
text = _clean_prompt_punctuation(str(qwen_prompt or "").lower().replace(",", " "))
|
|
horizontal_angle = 0
|
|
vertical_angle = 0
|
|
zoom = 5.0
|
|
for label, value in QWEN_CAMERA_DIRECTIONS.items():
|
|
if label in text:
|
|
horizontal_angle = value
|
|
break
|
|
for label, value in QWEN_CAMERA_ELEVATIONS.items():
|
|
if label in text:
|
|
vertical_angle = value
|
|
break
|
|
for label, value in QWEN_CAMERA_ZOOMS.items():
|
|
if label in text:
|
|
zoom = value
|
|
break
|
|
return horizontal_angle, vertical_angle, zoom
|
|
|
|
|
|
def _camera_info_dict(camera_info: Any) -> dict[str, Any] | None:
|
|
if not camera_info:
|
|
return None
|
|
if isinstance(camera_info, dict):
|
|
return camera_info
|
|
if isinstance(camera_info, str):
|
|
try:
|
|
raw = json.loads(camera_info)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return raw if isinstance(raw, dict) else None
|
|
return None
|
|
|
|
|
|
def _qwen_camera_info_values(camera_info: Any) -> tuple[int, int, float] | None:
|
|
info = _camera_info_dict(camera_info)
|
|
if not info:
|
|
return None
|
|
position = info.get("position") if isinstance(info.get("position"), dict) else {}
|
|
target = info.get("target") if isinstance(info.get("target"), dict) else {}
|
|
try:
|
|
dx = float(position.get("x", 0.0)) - float(target.get("x", 0.0))
|
|
dy = float(position.get("y", QWEN_CAMERA_SCENE_CENTER_Y)) - float(
|
|
target.get("y", QWEN_CAMERA_SCENE_CENTER_Y)
|
|
)
|
|
dz = float(position.get("z", 0.0)) - float(target.get("z", 0.0))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
distance = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
if distance <= 0:
|
|
return None
|
|
horizontal_angle = int(round(math.degrees(math.atan2(dx, dz)))) % 360
|
|
vertical_angle = int(round(math.degrees(math.asin(max(-1.0, min(1.0, dy / distance))))))
|
|
zoom = max(0.0, min(10.0, ((2.6 - distance) / 2.0) * 10.0))
|
|
return horizontal_angle, vertical_angle, round(zoom, 2)
|
|
|
|
|
|
def build_qwen_camera_config_json(
|
|
qwen_prompt: str = "",
|
|
camera_info: Any = None,
|
|
prefer_camera_info: bool = True,
|
|
camera_mode: str = "standard",
|
|
subject_focus: str = "auto",
|
|
lens: str = "auto",
|
|
orientation: str = "auto",
|
|
phone_visibility: str = "auto",
|
|
priority: str = "locked",
|
|
camera_detail: str = "compact",
|
|
include_degrees: bool = False,
|
|
suppress_phone_visibility: bool = True,
|
|
) -> str:
|
|
info_values = _qwen_camera_info_values(camera_info)
|
|
if prefer_camera_info and info_values is not None:
|
|
horizontal_angle, vertical_angle, zoom = info_values
|
|
source = "qwen_multiangle_camera_info"
|
|
else:
|
|
horizontal_angle, vertical_angle, zoom = _qwen_prompt_camera_values(qwen_prompt)
|
|
source = "qwen_multiangle_prompt"
|
|
config = json.loads(
|
|
build_camera_orbit_config_json(
|
|
enabled=True,
|
|
camera_mode=camera_mode,
|
|
horizontal_angle=horizontal_angle,
|
|
vertical_angle=vertical_angle,
|
|
zoom=zoom,
|
|
framing="from_zoom",
|
|
subject_focus=subject_focus,
|
|
lens=lens,
|
|
orientation=orientation,
|
|
phone_visibility="auto" if not _is_false(suppress_phone_visibility) else phone_visibility,
|
|
priority=priority,
|
|
camera_detail=camera_detail,
|
|
include_degrees=include_degrees,
|
|
)
|
|
)
|
|
config["camera_source"] = source
|
|
config["qwen_prompt"] = str(qwen_prompt or "").strip()
|
|
if info_values is not None:
|
|
config["qwen_camera_info_values"] = {
|
|
"horizontal_angle": info_values[0],
|
|
"vertical_angle": info_values[1],
|
|
"zoom": info_values[2],
|
|
}
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _choice(value: Any, choices: dict[str, str], default: str) -> str:
|
|
value = str(value or default)
|
|
return value if value in choices else default
|
|
|
|
|
|
def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
defaults = {
|
|
"camera_mode": "standard",
|
|
"shot_size": "auto",
|
|
"angle": "auto",
|
|
"lens": "auto",
|
|
"distance": "auto",
|
|
"orientation": "auto",
|
|
"phone_visibility": "auto",
|
|
"priority": "strong",
|
|
"camera_detail": "compact",
|
|
}
|
|
if not camera_config:
|
|
return defaults
|
|
if isinstance(camera_config, dict):
|
|
raw = camera_config
|
|
else:
|
|
try:
|
|
raw = json.loads(str(camera_config))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid camera_config JSON: {exc}") from exc
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("camera_config must be a JSON object")
|
|
parsed = {**defaults, **raw}
|
|
custom_camera_prompt = _clean_prompt_punctuation(parsed.get("custom_camera_prompt", "")).rstrip(".")
|
|
camera_source = str(parsed.get("camera_source") or "")
|
|
normalized = {
|
|
"camera_mode": _choice(parsed.get("camera_mode"), CAMERA_MODE_PROMPTS, defaults["camera_mode"]),
|
|
"shot_size": _choice(parsed.get("shot_size"), CAMERA_SHOT_PROMPTS, defaults["shot_size"]),
|
|
"angle": _choice(parsed.get("angle"), CAMERA_ANGLE_PROMPTS, defaults["angle"]),
|
|
"lens": _choice(parsed.get("lens"), CAMERA_LENS_PROMPTS, defaults["lens"]),
|
|
"distance": _choice(parsed.get("distance"), CAMERA_DISTANCE_PROMPTS, defaults["distance"]),
|
|
"orientation": _choice(parsed.get("orientation"), CAMERA_ORIENTATION_PROMPTS, defaults["orientation"]),
|
|
"phone_visibility": _choice(parsed.get("phone_visibility"), CAMERA_PHONE_PROMPTS, defaults["phone_visibility"]),
|
|
"priority": _choice(parsed.get("priority"), CAMERA_PRIORITY_PROMPTS, defaults["priority"]),
|
|
"camera_detail": str(parsed.get("camera_detail") or defaults["camera_detail"])
|
|
if str(parsed.get("camera_detail") or defaults["camera_detail"]) in CAMERA_DETAIL_CHOICES
|
|
else defaults["camera_detail"],
|
|
}
|
|
if custom_camera_prompt:
|
|
normalized["custom_camera_prompt"] = custom_camera_prompt
|
|
if camera_source:
|
|
normalized["camera_source"] = camera_source
|
|
for key in (
|
|
"orbit_azimuth",
|
|
"orbit_elevation",
|
|
"orbit_zoom",
|
|
"orbit_direction",
|
|
"orbit_elevation_label",
|
|
"orbit_distance_label",
|
|
"orbit_framing",
|
|
"orbit_focus",
|
|
):
|
|
if key in parsed:
|
|
normalized[key] = parsed[key]
|
|
return normalized
|
|
|
|
|
|
def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, Any]:
|
|
parsed = _parse_camera_config(camera_config)
|
|
if camera_mode and camera_mode != "from_camera_config":
|
|
parsed["camera_mode"] = _choice(camera_mode, CAMERA_MODE_PROMPTS, parsed["camera_mode"])
|
|
return parsed
|
|
|
|
|
|
def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
|
|
parsed = _parse_camera_config(camera_config)
|
|
if parsed["camera_detail"] == "off" or parsed["camera_mode"] == "disabled":
|
|
return "", parsed
|
|
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
|
|
if parsed["camera_detail"] == "compact":
|
|
values = [
|
|
parsed["camera_mode"],
|
|
parsed["shot_size"],
|
|
parsed["angle"],
|
|
parsed["lens"],
|
|
parsed["distance"],
|
|
parsed["orientation"],
|
|
parsed["phone_visibility"],
|
|
]
|
|
labels = [CAMERA_COMPACT_LABELS.get(value, value.replace("_", " ")) for value in values]
|
|
labels = [label for value, label in zip(values, labels) if label and value != "auto"]
|
|
if custom_camera_prompt:
|
|
labels.append(custom_camera_prompt)
|
|
if not labels:
|
|
return "", parsed
|
|
directive = "Camera: " + ", ".join(labels) + "."
|
|
if parsed["priority"] == "locked":
|
|
directive += " Keep this camera framing."
|
|
return directive, parsed
|
|
parts = [
|
|
CAMERA_MODE_PROMPTS[parsed["camera_mode"]],
|
|
CAMERA_SHOT_PROMPTS[parsed["shot_size"]],
|
|
CAMERA_ANGLE_PROMPTS[parsed["angle"]],
|
|
CAMERA_LENS_PROMPTS[parsed["lens"]],
|
|
CAMERA_DISTANCE_PROMPTS[parsed["distance"]],
|
|
CAMERA_ORIENTATION_PROMPTS[parsed["orientation"]],
|
|
CAMERA_PHONE_PROMPTS[parsed["phone_visibility"]],
|
|
]
|
|
if custom_camera_prompt:
|
|
parts.append(f"Camera orbit: {custom_camera_prompt}.")
|
|
parts = [part for part in parts if part]
|
|
if not parts:
|
|
return "", parsed
|
|
parts.append(CAMERA_PRIORITY_PROMPTS[parsed["priority"]])
|
|
return " ".join(parts), parsed
|
|
|
|
|
|
def _insert_positive_directive(prompt: str, directive: str) -> str:
|
|
marker = " Avoid:"
|
|
if marker in prompt:
|
|
before, after = prompt.split(marker, 1)
|
|
return f"{before.rstrip()} {directive}{marker}{after}"
|
|
return f"{prompt.rstrip()} {directive}"
|
|
|
|
|
|
def _camera_caption_text(parsed: dict[str, Any]) -> str:
|
|
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
|
|
if custom_camera_prompt:
|
|
return custom_camera_prompt
|
|
camera_mode = str(parsed.get("camera_mode") or "").replace("_", " ").strip()
|
|
if not camera_mode or camera_mode == "standard":
|
|
return ""
|
|
return f"{camera_mode} camera framing"
|
|
|
|
|
|
def _is_coworking_scene(scene_text: Any) -> bool:
|
|
text = str(scene_text or "").lower()
|
|
return any(
|
|
term in text
|
|
for term in (
|
|
"coworking",
|
|
"cowork",
|
|
"office lounge",
|
|
"business cafe",
|
|
"work cafe",
|
|
"shared office",
|
|
"corporate office",
|
|
"office after hours",
|
|
"laptops",
|
|
"warm desks",
|
|
"repeating desks",
|
|
"glass partitions",
|
|
"copier alcove",
|
|
)
|
|
)
|
|
|
|
|
|
def _camera_geometry_phrase(parsed: dict[str, Any]) -> str:
|
|
direction = str(parsed.get("orbit_direction") or "").strip()
|
|
elevation = str(parsed.get("orbit_elevation_label") or "").strip()
|
|
distance = str(parsed.get("orbit_distance_label") or "").strip()
|
|
custom = str(parsed.get("custom_camera_prompt") or "").strip()
|
|
if not any((direction, elevation, distance)) and custom:
|
|
return custom
|
|
parts = [part for part in (direction, elevation, distance) if part and part != "auto"]
|
|
if parts:
|
|
return ", ".join(parts)
|
|
compact_parts = [
|
|
CAMERA_COMPACT_LABELS.get(str(parsed.get(key) or ""), str(parsed.get(key) or "").replace("_", " "))
|
|
for key in ("shot_size", "angle", "distance")
|
|
]
|
|
compact_parts = [part for part in compact_parts if part and part != "auto"]
|
|
return ", ".join(compact_parts)
|
|
|
|
|
|
def _camera_direction_from_text(text: Any) -> str:
|
|
source = str(text or "").lower()
|
|
for label in (
|
|
"front-right quarter view",
|
|
"right side view",
|
|
"back-right quarter view",
|
|
"back view",
|
|
"back-left quarter view",
|
|
"left side view",
|
|
"front-left quarter view",
|
|
"front view",
|
|
):
|
|
if label in source:
|
|
return label
|
|
return ""
|
|
|
|
|
|
def _camera_elevation_from_text(text: Any) -> str:
|
|
source = str(text or "").lower()
|
|
for label in ("low-angle shot", "eye-level shot", "elevated shot", "high-angle shot"):
|
|
if label in source:
|
|
return label
|
|
return ""
|
|
|
|
|
|
def _camera_distance_from_text(text: Any) -> str:
|
|
source = str(text or "").lower()
|
|
for label in ("wide shot", "full-body shot", "three-quarter body shot", "medium shot", "close-up", "extreme close-up"):
|
|
if label in source:
|
|
return label
|
|
return ""
|
|
|
|
|
|
def _coworking_location_profile(scene_text: Any) -> dict[str, str]:
|
|
text = str(scene_text or "").lower()
|
|
if "business cafe" in text or "work cafe" in text or "cafe" in text:
|
|
return {
|
|
"layout_label": "Business cafe camera layout",
|
|
"place": "business cafe coworking counter",
|
|
"foreground": "counter edge, laptop corner, and small plant",
|
|
"midground": "bar stools, warm desk lamps, and coffee-counter work spots",
|
|
"background": "plants, mirror strip, menu wall, and repeated cafe work tables",
|
|
}
|
|
if "corporate office" in text or "office after hours" in text or "copier" in text:
|
|
return {
|
|
"layout_label": "Office camera layout",
|
|
"place": "empty after-hours office",
|
|
"foreground": "copier alcove edge, chair backs, and nearest desk corner",
|
|
"midground": "repeating desks, glass partition seams, and muted monitor glow",
|
|
"background": "rows of empty workstations, city-light windows, and quiet office depth",
|
|
}
|
|
return {
|
|
"layout_label": "Coworking camera layout",
|
|
"place": "coworking lounge",
|
|
"foreground": "near desk edge, laptop corner, and chair back",
|
|
"midground": "warm work desks, laptop tables, and glass partition seams",
|
|
"background": "tall windows, repeated desk rows, plants, and soft shared-office depth",
|
|
}
|
|
|
|
|
|
def _coworking_subject_terms(subject_kind: str, pov_labels: list[str] | None = None) -> tuple[str, str]:
|
|
if pov_labels:
|
|
return "the visible partner", "them"
|
|
if subject_kind == "woman":
|
|
return "the woman", "her"
|
|
if subject_kind == "man":
|
|
return "the man", "him"
|
|
if subject_kind == "couple":
|
|
return "the couple", "them"
|
|
return "the subjects", "them"
|
|
|
|
|
|
def _coworking_direction_detail(
|
|
direction: str,
|
|
profile: dict[str, str],
|
|
pov_labels: list[str] | None = None,
|
|
subject_kind: str = "subjects",
|
|
) -> str:
|
|
direction = str(direction or "").strip().lower()
|
|
foreground = profile["foreground"]
|
|
midground = profile["midground"]
|
|
background = profile["background"]
|
|
subject, pronoun = _coworking_subject_terms(subject_kind, pov_labels)
|
|
if pov_labels:
|
|
if "right side" in direction:
|
|
return f"{subject} is in right-side profile; {midground} run behind {pronoun} toward {background}, with coworking details kept at the frame edges"
|
|
if "left side" in direction:
|
|
return f"{subject} is in left-side profile; {midground} run behind {pronoun} toward {background}, with coworking details kept at the frame edges"
|
|
if "back-right" in direction or "back-left" in direction:
|
|
return f"{subject} stays close in one continuous diagonal first-person body angle; {midground} lead toward {background} behind {pronoun} at the edges, not in the lower foreground"
|
|
if direction == "back view":
|
|
return f"the viewer looks past {subject}'s back toward {midground}, then into {background}; only POV body cues sit low in frame"
|
|
if "front-right" in direction or "front-left" in direction:
|
|
return f"{subject} fills the first-person front-quarter view; {midground} recede diagonally behind {pronoun} toward {background}"
|
|
return f"{subject} faces the viewer in first-person view; {midground} and {background} stay behind {pronoun}, not between viewer and body"
|
|
if "right side" in direction or "left side" in direction:
|
|
return f"{subject} is held in side profile along the {foreground}; {midground} run laterally behind {pronoun}, with {background} still readable"
|
|
if "back-right" in direction or "back-left" in direction:
|
|
return f"{subject} is viewed from a rear-quarter angle, partly turning back toward camera; the {foreground} stays low in frame while {midground} lead into {background}"
|
|
if direction == "back view":
|
|
return f"{subject} is seen from behind with the {foreground} at camera side, facing into {midground} and {background}"
|
|
if "front-right" in direction or "front-left" in direction:
|
|
return f"{subject} is placed beside the {foreground}; {midground} recede diagonally behind {pronoun} toward {background}"
|
|
return f"{subject} faces camera beside the {foreground}; {midground} sit between {pronoun} and {background}"
|
|
|
|
|
|
def _coworking_distance_detail(distance: str, profile: dict[str, str], subject_kind: str, pov_labels: list[str] | None = None) -> str:
|
|
distance = str(distance or "").strip().lower()
|
|
subject, _pronoun = _coworking_subject_terms(subject_kind, pov_labels)
|
|
if pov_labels:
|
|
if "wide" in distance or "full-body" in distance or "full body" in distance:
|
|
return f"wide POV keeps {subject} readable with coworking context behind them"
|
|
if "close" in distance:
|
|
return f"close POV keeps {subject} dominant with coworking context only at the sides or background"
|
|
return f"medium POV keeps {subject} dominant with room context behind them"
|
|
if "wide" in distance or "full-body" in distance or "full body" in distance:
|
|
return "wide crop keeps floor aisle, table rows, and window depth readable"
|
|
if "close" in distance:
|
|
return "close crop keeps one desk or counter anchor visible"
|
|
return f"medium crop keeps {subject} dominant"
|
|
|
|
|
|
def _coworking_elevation_detail(elevation: str, profile: dict[str, str], subject_kind: str, pov_labels: list[str] | None = None) -> str:
|
|
elevation = str(elevation or "").strip().lower()
|
|
subject, pronoun = _coworking_subject_terms(subject_kind, pov_labels)
|
|
if pov_labels:
|
|
if "low-angle" in elevation:
|
|
return f"low angle keeps POV body cues low while windows and partition lines rise behind {pronoun}"
|
|
if "elevated" in elevation:
|
|
return f"elevated POV keeps the viewer's eye line slightly higher than {subject}, with tabletop and glass lines only behind or at the side edges"
|
|
if "high-angle" in elevation:
|
|
return f"high angle looks down from the viewer's position with desks and aisle only in the background"
|
|
return f"eye-level angle keeps tabletop lines and glass seams behind {pronoun}"
|
|
if "low-angle" in elevation:
|
|
return f"low angle keeps the foreground desk edge low while windows and partitions rise behind {pronoun}"
|
|
if "elevated" in elevation:
|
|
return f"elevated angle shows tabletop surfaces, laptop shapes, chairs, and walking aisle around {pronoun}"
|
|
if "high-angle" in elevation:
|
|
return f"high angle shows the desk grid, chairs, floor aisle, and placement of {pronoun}"
|
|
return f"eye-level angle keeps tabletop lines and glass seams straight"
|
|
|
|
|
|
def _coworking_camera_scene_directive(
|
|
scene_text: Any,
|
|
parsed: dict[str, Any],
|
|
pov_labels: list[str] | None = None,
|
|
subject_kind: str = "subjects",
|
|
) -> str:
|
|
if not _is_coworking_scene(scene_text):
|
|
return ""
|
|
direction = str(parsed.get("orbit_direction") or "").strip()
|
|
elevation = str(parsed.get("orbit_elevation_label") or "").strip()
|
|
distance = str(parsed.get("orbit_distance_label") or "").strip()
|
|
custom_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
|
|
direction = direction or _camera_direction_from_text(custom_prompt)
|
|
elevation = elevation or _camera_elevation_from_text(custom_prompt)
|
|
distance = distance or _camera_distance_from_text(custom_prompt)
|
|
if not any((direction, elevation, distance, custom_prompt)):
|
|
return ""
|
|
profile = _coworking_location_profile(scene_text)
|
|
direction_detail = _coworking_direction_detail(direction, profile, pov_labels, subject_kind)
|
|
distance_detail = _coworking_distance_detail(distance, profile, subject_kind, pov_labels)
|
|
elevation_detail = _coworking_elevation_detail(elevation, profile, subject_kind, pov_labels)
|
|
if pov_labels:
|
|
return (
|
|
f"{profile['layout_label']} from POV: {direction_detail}. "
|
|
f"{distance_detail}; {elevation_detail}; use the multiangle camera only as first-person spatial geometry."
|
|
)
|
|
geometry = _camera_geometry_phrase(parsed)
|
|
geometry_clause = f" ({geometry})" if geometry else ""
|
|
return (
|
|
f"{profile['layout_label']}{geometry_clause}: {direction_detail}; "
|
|
f"{distance_detail}; {elevation_detail}."
|
|
)
|
|
|
|
|
|
def _coworking_composition_prompt(scene_text: Any, composition: Any, subject_kind: str = "subjects") -> str:
|
|
text = str(composition or "").strip()
|
|
if not text or not _is_coworking_scene(scene_text):
|
|
return text
|
|
lower = text.lower()
|
|
if not any(term in lower for term in ("office-lobby", "office lobby", "walking composition", "outfit-check")):
|
|
return text
|
|
subject, _pronoun = _coworking_subject_terms(subject_kind)
|
|
if subject_kind == "woman":
|
|
return "coworking lounge selfie frame with the woman near a desk edge and tall-window depth behind her"
|
|
if subject_kind == "man":
|
|
return "coworking lounge portrait frame with the man near a desk edge and tall-window depth behind him"
|
|
return f"coworking lounge frame with {subject} near a desk edge and tall-window depth behind them"
|
|
|
|
|
|
def _apply_coworking_composition(row: dict[str, Any], subject_kind: str) -> dict[str, Any]:
|
|
scene_text = row.get("scene_text") or row.get("source_scene_text") or row.get("scene")
|
|
old_composition = str(row.get("composition") or "").strip()
|
|
new_composition = _coworking_composition_prompt(scene_text, old_composition, subject_kind)
|
|
if not old_composition or new_composition == old_composition:
|
|
return row
|
|
row["source_composition"] = row.get("source_composition") or old_composition
|
|
row["composition"] = new_composition
|
|
row["composition_prompt"] = _composition_prompt(new_composition)
|
|
prompt = str(row.get("prompt") or "")
|
|
replacements = (
|
|
(f"Composition: vertical {old_composition}.", f"Composition: {_composition_prompt(new_composition)}."),
|
|
(f"Composition: {old_composition}.", f"Composition: {_composition_prompt(new_composition)}."),
|
|
(f"Framed as {old_composition}.", f"Framed as {new_composition}."),
|
|
)
|
|
for old_fragment, new_fragment in replacements:
|
|
if old_fragment in prompt:
|
|
row["prompt"] = prompt.replace(old_fragment, new_fragment)
|
|
break
|
|
row["caption"] = str(row.get("caption") or "").replace(f", {old_composition},", f", {new_composition},")
|
|
return row
|
|
|
|
|
|
def _camera_scene_directive_for_context(
|
|
scene_text: Any,
|
|
composition: Any,
|
|
camera_config: str | dict[str, Any] | None,
|
|
pov_labels: list[str] | None = None,
|
|
subject_kind: str = "subjects",
|
|
) -> tuple[str, dict[str, Any]]:
|
|
parsed = _parse_camera_config(camera_config)
|
|
if parsed["camera_detail"] == "off" or parsed["camera_mode"] == "disabled":
|
|
return "", parsed
|
|
return _coworking_camera_scene_directive(scene_text, parsed, pov_labels, subject_kind), parsed
|
|
|
|
|
|
def _row_camera_subject_kind(row: dict[str, Any]) -> str:
|
|
subject_type = str(row.get("subject_type") or row.get("primary_subject") or "").lower()
|
|
if subject_type in ("woman", "adult woman") or subject_type == "single_any":
|
|
return "woman"
|
|
if subject_type in ("man", "adult man"):
|
|
return "man"
|
|
try:
|
|
women_count = int(row.get("women_count") or 0)
|
|
men_count = int(row.get("men_count") or 0)
|
|
except (TypeError, ValueError):
|
|
women_count = men_count = 0
|
|
if women_count == 1 and men_count == 0:
|
|
return "woman"
|
|
if women_count == 0 and men_count == 1:
|
|
return "man"
|
|
if women_count + men_count == 2:
|
|
return "couple"
|
|
return "subjects"
|
|
|
|
|
|
def _apply_camera_config(row: dict[str, Any], camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
directive, parsed = _camera_directive(camera_config)
|
|
pov_labels = _pov_character_labels(
|
|
_character_slot_label_map(_parse_character_cast(row.get("character_cast_slots"))),
|
|
int(row.get("men_count") or 0) if str(row.get("men_count") or "").isdigit() else 0,
|
|
)
|
|
if not pov_labels:
|
|
pov_labels = [str(label) for label in _list_from(row.get("pov_character_labels")) if str(label).strip()]
|
|
subject_kind = _row_camera_subject_kind(row)
|
|
row = _apply_coworking_composition(row, subject_kind)
|
|
scene_directive, parsed = _camera_scene_directive_for_context(
|
|
row.get("scene_text") or row.get("source_scene_text") or row.get("scene"),
|
|
row.get("composition") or row.get("source_composition"),
|
|
parsed,
|
|
pov_labels,
|
|
subject_kind,
|
|
)
|
|
row["camera_config"] = parsed
|
|
row["camera_scene_directive"] = scene_directive
|
|
row["camera_directive"] = "" if pov_labels else directive
|
|
combined_directive = " ".join(part for part in (scene_directive, row["camera_directive"]) if part)
|
|
if not combined_directive:
|
|
return row
|
|
row["prompt"] = _insert_positive_directive(row["prompt"], combined_directive)
|
|
camera_caption = _camera_caption_text(parsed)
|
|
if camera_caption and not pov_labels:
|
|
row["caption"] = f"{row.get('caption', '').rstrip()}, {camera_caption}"
|
|
return row
|
|
|
|
|
|
def _row_seed(seed: int, row_number: int, salt: int = 0) -> int:
|
|
return int(seed) + int(row_number) * 1009 + salt * 9176
|
|
|
|
|
|
def _pick_clothing_mode(rng: random.Random, clothing: str, minimal_ratio: float | None) -> str:
|
|
if clothing == "random":
|
|
return "minimal" if rng.random() < 0.5 else "full"
|
|
if minimal_ratio is None:
|
|
return clothing
|
|
return "minimal" if rng.random() < minimal_ratio else "full"
|
|
|
|
|
|
def _pick_pose_mode(rng: random.Random, poses: str, standard_ratio: float | None) -> str:
|
|
if poses == "random":
|
|
return "standard" if rng.random() < 0.5 else "evocative"
|
|
if standard_ratio is None:
|
|
return poses
|
|
return "standard" if rng.random() < standard_ratio else "evocative"
|
|
|
|
|
|
def _pick_figure_bias(rng: random.Random, figure: str) -> str:
|
|
if figure in ("curvy", "balanced", "bombshell"):
|
|
return figure
|
|
return g.choose(rng, ["curvy", "balanced", "bombshell"])
|
|
|
|
|
|
def _pick_expression_intensity(rng: random.Random, expression_intensity: Any) -> tuple[float, str]:
|
|
try:
|
|
value = float(expression_intensity)
|
|
except (TypeError, ValueError):
|
|
return 0.5, "default"
|
|
if value < 0:
|
|
return round(rng.random(), 2), "random"
|
|
return _clamped_float(value, 0.5), "input"
|
|
|
|
|
|
def _build_auto_weighted_row(
|
|
row_number: int,
|
|
start_index: int,
|
|
clothing: str,
|
|
ethnicity: str,
|
|
poses: str,
|
|
backside_bias: float,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
minimal_clothing_ratio: float | None,
|
|
standard_pose_ratio: float | None,
|
|
seed: int,
|
|
) -> dict[str, Any]:
|
|
batch_number = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1)
|
|
rows = g.build_rows(
|
|
batch_number * g.BATCH_SIZE,
|
|
start_index,
|
|
clothing,
|
|
ethnicity,
|
|
poses,
|
|
backside_bias,
|
|
figure,
|
|
no_plus_women,
|
|
no_black,
|
|
minimal_clothing_ratio,
|
|
standard_pose_ratio,
|
|
seed,
|
|
g.EXPRESSION_SEED + seed,
|
|
)
|
|
row = rows[row_number - 1]
|
|
row["main_category"] = "auto_weighted"
|
|
row["subcategory"] = row.get("primary_subject", "auto")
|
|
row["source"] = "built_in_generator"
|
|
return row
|
|
|
|
|
|
def _build_direct_builtin_row(
|
|
category: str,
|
|
row_number: int,
|
|
start_index: int,
|
|
clothing: str,
|
|
ethnicity: str,
|
|
poses: str,
|
|
backside_bias: float,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
minimal_clothing_ratio: float | None,
|
|
standard_pose_ratio: float | None,
|
|
seed: int,
|
|
) -> dict[str, Any]:
|
|
rng = random.Random(_row_seed(seed, row_number))
|
|
expr_deck = g.ExpressionDeck(g.EXPRESSIONS, random.Random(_row_seed(g.EXPRESSION_SEED + seed, row_number)))
|
|
batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1)
|
|
index = start_index + row_number - 1
|
|
row_clothing = _pick_clothing_mode(rng, clothing, minimal_clothing_ratio)
|
|
row_poses = _pick_pose_mode(rng, poses, standard_pose_ratio)
|
|
|
|
if category == "woman":
|
|
row = g.make_single(
|
|
index,
|
|
batch,
|
|
rng,
|
|
"woman",
|
|
expr_deck,
|
|
row_clothing,
|
|
ethnicity,
|
|
row_poses,
|
|
backside_bias,
|
|
figure,
|
|
no_plus_women,
|
|
no_black,
|
|
)
|
|
elif category == "man":
|
|
row = g.make_single(index, batch, rng, "man", expr_deck, row_clothing, ethnicity, row_poses, backside_bias, figure)
|
|
elif category == "couple":
|
|
row = g.make_couple(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women)
|
|
elif category == "group_or_layout":
|
|
row = g.make_group_or_layout(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women)
|
|
else:
|
|
raise ValueError(f"Unknown built-in category: {category}")
|
|
|
|
row["main_category"] = category
|
|
row["subcategory"] = row.get("pose_mode", category)
|
|
row["source"] = "built_in_generator"
|
|
return row
|
|
|
|
|
|
def _auto_full_choice(seed_config: dict[str, int], seed: int, row_number: int) -> str:
|
|
categories = load_category_library()
|
|
if not categories:
|
|
return "auto_weighted"
|
|
category_rng = _axis_rng(seed_config, "category", seed, row_number)
|
|
choices: list[dict[str, Any]] = [{"category": "auto_weighted", "weight": 1.0}]
|
|
choices.extend(
|
|
{
|
|
"category": category["name"],
|
|
"weight": category.get("weight", 1.0),
|
|
}
|
|
for category in categories
|
|
)
|
|
choice = _weighted_choice(category_rng, choices)
|
|
return str(choice.get("category") or "auto_weighted")
|
|
|
|
|
|
def _find_category(categories: list[dict[str, Any]], name_or_slug: str) -> dict[str, Any] | None:
|
|
wanted = name_or_slug.strip().lower()
|
|
for category in categories:
|
|
if category["name"].lower() == wanted or category["slug"].lower() == wanted:
|
|
return category
|
|
return None
|
|
|
|
|
|
def _base_cast_counts(women_count: int, men_count: int) -> tuple[int, int]:
|
|
women_count = max(0, int(women_count))
|
|
men_count = max(0, int(men_count))
|
|
if women_count + men_count == 0:
|
|
women_count = 1
|
|
return women_count, men_count
|
|
|
|
|
|
def _counts_for_exact_subcategory(
|
|
subcategory: dict[str, Any],
|
|
women_count: int,
|
|
men_count: int,
|
|
) -> tuple[int, int]:
|
|
women_count, men_count = _base_cast_counts(women_count, men_count)
|
|
|
|
min_women = _constraint_int(subcategory, "min_women")
|
|
if min_women is not None and women_count < min_women:
|
|
women_count = min_women
|
|
min_men = _constraint_int(subcategory, "min_men")
|
|
if min_men is not None and men_count < min_men:
|
|
men_count = min_men
|
|
|
|
min_people = _constraint_int(subcategory, "min_people")
|
|
if min_people is not None:
|
|
missing = min_people - (women_count + men_count)
|
|
if missing > 0:
|
|
if women_count > 0 or men_count == 0:
|
|
women_count += missing
|
|
else:
|
|
men_count += missing
|
|
return women_count, men_count
|
|
|
|
|
|
def _find_subcategory(
|
|
categories: list[dict[str, Any]],
|
|
category_choice: str,
|
|
subcategory_choice: str,
|
|
category_rng: random.Random,
|
|
subcategory_rng: random.Random,
|
|
women_count: int = 1,
|
|
men_count: int = 1,
|
|
) -> tuple[dict[str, Any], dict[str, Any], int, int]:
|
|
women_count, men_count = _base_cast_counts(women_count, men_count)
|
|
if subcategory_choice and subcategory_choice != RANDOM_SUBCATEGORY and " / " in subcategory_choice:
|
|
category_name, subcategory_name = subcategory_choice.split(" / ", 1)
|
|
category = _find_category(categories, category_name)
|
|
if not category:
|
|
raise ValueError(f"Unknown category in subcategory picker: {category_name}")
|
|
wanted = subcategory_name.strip().lower()
|
|
for subcategory in category["subcategories"]:
|
|
if subcategory["name"].lower() == wanted or subcategory["slug"].lower() == wanted:
|
|
adjusted_women_count, adjusted_men_count = _counts_for_exact_subcategory(
|
|
subcategory,
|
|
women_count,
|
|
men_count,
|
|
)
|
|
if not _compatible_entry(subcategory, adjusted_women_count, adjusted_men_count):
|
|
raise ValueError(
|
|
f"Subcategory '{subcategory['name']}' is not compatible with "
|
|
f"women_count={women_count}, men_count={men_count}"
|
|
)
|
|
return category, subcategory, adjusted_women_count, adjusted_men_count
|
|
raise ValueError(f"Unknown subcategory '{subcategory_name}' for category '{category_name}'")
|
|
|
|
if category_choice == "custom_random":
|
|
if not categories:
|
|
raise ValueError("No custom categories found in categories/*.json")
|
|
category = _weighted_choice(category_rng, categories)
|
|
else:
|
|
category = _find_category(categories, category_choice)
|
|
if not category:
|
|
raise ValueError(f"Unknown custom category: {category_choice}")
|
|
subcategories = _compatible_entries(category["subcategories"], women_count, men_count)
|
|
subcategory = _weighted_choice(subcategory_rng, subcategories)
|
|
return category, subcategory, women_count, men_count
|
|
|
|
|
|
def _merged_field(category: dict[str, Any], subcategory: dict[str, Any], item: Any, key: str, default: Any = None) -> Any:
|
|
if isinstance(item, dict) and key in item:
|
|
return item[key]
|
|
if key in subcategory:
|
|
return subcategory[key]
|
|
if key in category:
|
|
return category[key]
|
|
return default
|
|
|
|
|
|
def _body_phrase(body: Any, figure_note: Any = "") -> str:
|
|
body = str(body or "").strip()
|
|
figure_note = str(figure_note or "").strip()
|
|
if not body:
|
|
return figure_note
|
|
if not figure_note:
|
|
return f"{body} figure"
|
|
if "figure" in figure_note.lower():
|
|
return f"{body} build and {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
|
|
|
|
|
|
CHARACTER_MANUAL_FIELDS = (
|
|
"manual_age",
|
|
"manual_body",
|
|
"body_phrase",
|
|
"skin",
|
|
"hair",
|
|
"eyes",
|
|
"softcore_outfit",
|
|
"hardcore_clothing",
|
|
)
|
|
|
|
|
|
def _parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]:
|
|
if not value:
|
|
return {}
|
|
if isinstance(value, dict):
|
|
raw = value
|
|
else:
|
|
try:
|
|
raw = json.loads(str(value))
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
return {
|
|
key: str(raw.get(key) or "").strip()
|
|
for key in CHARACTER_MANUAL_FIELDS
|
|
if str(raw.get(key) or "").strip()
|
|
}
|
|
|
|
|
|
def _character_manual_summary(config: dict[str, str]) -> str:
|
|
parts = [f"{key}={value}" for key, value in config.items() if value]
|
|
return "; ".join(parts) if parts else "manual unrestricted"
|
|
|
|
|
|
def build_character_manual_config_json(
|
|
manual: str | dict[str, Any] | None = "",
|
|
combine_mode: str = "merge_nonempty",
|
|
manual_age: str = "",
|
|
manual_body: str = "",
|
|
body_phrase: str = "",
|
|
skin: str = "",
|
|
hair: str = "",
|
|
eyes: str = "",
|
|
softcore_outfit: str = "",
|
|
hardcore_clothing: str = "",
|
|
) -> str:
|
|
base = {} if combine_mode == "replace_all" else _parse_character_manual_config(manual)
|
|
updates = {
|
|
"manual_age": manual_age,
|
|
"manual_body": manual_body,
|
|
"body_phrase": body_phrase,
|
|
"skin": skin,
|
|
"hair": hair,
|
|
"eyes": eyes,
|
|
"softcore_outfit": softcore_outfit,
|
|
"hardcore_clothing": hardcore_clothing,
|
|
}
|
|
for key, value in updates.items():
|
|
value = str(value or "").strip()
|
|
if value:
|
|
base[key] = value
|
|
result = {"config_type": "character_manual", **base}
|
|
result["summary"] = _character_manual_summary(base)
|
|
return json.dumps(result, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _slot_value(value: Any) -> str:
|
|
text = str(value or "").strip()
|
|
if text.lower() in CHARACTER_RANDOM_TOKENS:
|
|
return ""
|
|
return text
|
|
|
|
|
|
CHARACTER_CHARACTERISTIC_AXES = {
|
|
"ages": CHARACTER_AGE_CHOICES,
|
|
"bodies": list(dict.fromkeys([*CHARACTER_BODY_CHOICES, *CHARACTER_WOMAN_BODY_CHOICES, *CHARACTER_MAN_BODY_CHOICES])),
|
|
"eyes": CHARACTER_EYE_COLOR_CHOICES,
|
|
}
|
|
|
|
|
|
def _empty_characteristics_config() -> dict[str, Any]:
|
|
return {
|
|
"config_type": "characteristics",
|
|
"ages": [],
|
|
"bodies": [],
|
|
"eyes": [],
|
|
"softcore_outfits": [],
|
|
"hardcore_clothing": [],
|
|
}
|
|
|
|
|
|
def _normalize_characteristic_choice(value: Any, choices: list[str] | tuple[str, ...]) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return ""
|
|
normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
|
for choice in choices:
|
|
if normalized == re.sub(r"[^a-z0-9]+", "_", str(choice).lower()).strip("_"):
|
|
return str(choice)
|
|
return ""
|
|
|
|
|
|
def _normalize_characteristic_values(
|
|
values: Any,
|
|
choices: list[str] | tuple[str, ...] | None = None,
|
|
*,
|
|
allow_free_text: bool = False,
|
|
) -> list[str]:
|
|
if isinstance(values, str):
|
|
raw_values = [part.strip() for part in re.split(r"[\n;]+", values) if part.strip()]
|
|
if len(raw_values) == 1 and "," in raw_values[0] and not allow_free_text:
|
|
raw_values = [part.strip() for part in raw_values[0].split(",") if part.strip()]
|
|
elif isinstance(values, (list, tuple, set)):
|
|
raw_values = list(values)
|
|
else:
|
|
raw_values = []
|
|
normalized: list[str] = []
|
|
for raw_value in raw_values:
|
|
value = str(raw_value or "").strip() if choices is None else _normalize_characteristic_choice(raw_value, choices)
|
|
if not value or value in ("random", "manual"):
|
|
continue
|
|
if value not in normalized:
|
|
normalized.append(value)
|
|
return normalized
|
|
|
|
|
|
def _parse_characteristics_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
if not value:
|
|
return _empty_characteristics_config()
|
|
if isinstance(value, dict):
|
|
raw = value
|
|
else:
|
|
try:
|
|
raw = json.loads(str(value))
|
|
except json.JSONDecodeError:
|
|
return _empty_characteristics_config()
|
|
if not isinstance(raw, dict):
|
|
return _empty_characteristics_config()
|
|
return {
|
|
"config_type": "characteristics",
|
|
"ages": _normalize_characteristic_values(raw.get("ages"), CHARACTER_AGE_CHOICES),
|
|
"bodies": _normalize_characteristic_values(raw.get("bodies"), CHARACTER_CHARACTERISTIC_AXES["bodies"]),
|
|
"eyes": _normalize_characteristic_values(raw.get("eyes"), CHARACTER_EYE_COLOR_CHOICES),
|
|
"softcore_outfits": _normalize_characteristic_values(raw.get("softcore_outfits"), None, allow_free_text=True),
|
|
"hardcore_clothing": _normalize_characteristic_values(raw.get("hardcore_clothing"), None, allow_free_text=True),
|
|
}
|
|
|
|
|
|
def _characteristics_summary(config: dict[str, Any]) -> str:
|
|
parts = []
|
|
for key, label in (
|
|
("ages", "ages"),
|
|
("bodies", "bodies"),
|
|
("eyes", "eyes"),
|
|
("softcore_outfits", "soft_outfits"),
|
|
("hardcore_clothing", "hard_clothing"),
|
|
):
|
|
values = config.get(key) or []
|
|
if not values:
|
|
continue
|
|
if key in ("softcore_outfits", "hardcore_clothing"):
|
|
parts.append(f"{label}={len(values)}")
|
|
else:
|
|
parts.append(f"{label}={','.join(values)}")
|
|
return "; ".join(parts) if parts else "characteristics unrestricted"
|
|
|
|
|
|
def build_characteristics_config_json(
|
|
characteristics: str | dict[str, Any] | None = "",
|
|
axis: str = "ages",
|
|
selected_values: list[str] | tuple[str, ...] | str | None = None,
|
|
combine_mode: str = "replace_axis",
|
|
) -> str:
|
|
config = _parse_characteristics_config(characteristics)
|
|
axis_key = str(axis or "").strip().lower()
|
|
if axis_key not in config:
|
|
config["summary"] = _characteristics_summary(config)
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
choices = CHARACTER_CHARACTERISTIC_AXES.get(axis_key)
|
|
values = _normalize_characteristic_values(
|
|
selected_values,
|
|
choices,
|
|
allow_free_text=choices is None,
|
|
)
|
|
if combine_mode == "add_to_axis":
|
|
existing = list(config.get(axis_key) or [])
|
|
for value in values:
|
|
if value not in existing:
|
|
existing.append(value)
|
|
config[axis_key] = existing
|
|
else:
|
|
config[axis_key] = values
|
|
config["summary"] = _characteristics_summary(config)
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _characteristic_choice(config: dict[str, Any], key: str, rng: random.Random) -> str:
|
|
values = config.get(key) or []
|
|
return g.choose(rng, values) if values else ""
|
|
|
|
|
|
def _eye_phrase_from_key(key: str) -> str:
|
|
return {
|
|
"blue": "blue eyes",
|
|
"pale_blue": "pale blue eyes",
|
|
"ice_blue": "ice blue eyes",
|
|
"blue_gray": "blue-gray eyes",
|
|
"green": "green eyes",
|
|
"emerald_green": "emerald green eyes",
|
|
"hazel": "hazel eyes",
|
|
"light_hazel": "light hazel eyes",
|
|
"green_hazel": "green-hazel eyes",
|
|
"amber": "amber eyes",
|
|
"amber_brown": "amber-brown eyes",
|
|
"honey_brown": "honey-brown eyes",
|
|
"brown": "brown eyes",
|
|
"deep_brown": "deep brown eyes",
|
|
"dark_brown": "dark brown eyes",
|
|
"dark": "dark eyes",
|
|
"gray": "gray eyes",
|
|
"gray_brown": "gray-brown eyes",
|
|
}.get(key, "")
|
|
|
|
|
|
def _normalize_descriptor_detail(value: Any) -> str:
|
|
text = str(value or "auto").strip()
|
|
return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto"
|
|
|
|
|
|
def _normalize_presence_mode(value: Any, subject_type: str) -> str:
|
|
text = str(value or "visible").strip().lower()
|
|
if text not in CHARACTER_PRESENCE_CHOICES:
|
|
text = "visible"
|
|
if subject_type != "man":
|
|
return "visible"
|
|
return text
|
|
|
|
|
|
def _slot_is_pov(slot: dict[str, Any] | None) -> bool:
|
|
if not slot:
|
|
return False
|
|
return slot.get("subject_type") == "man" and slot.get("presence_mode") == "pov"
|
|
|
|
|
|
def _normalize_slot_expression_intensity(value: Any) -> float:
|
|
try:
|
|
intensity = float(value)
|
|
except (TypeError, ValueError):
|
|
return -1.0
|
|
if intensity < 0:
|
|
return -1.0
|
|
return _clamped_float(intensity, 0.5)
|
|
|
|
|
|
def _slot_expression_enabled(slot: dict[str, Any] | None) -> bool:
|
|
if not slot:
|
|
return True
|
|
return not _is_false(slot.get("expression_enabled", True))
|
|
|
|
|
|
def _slot_expression_intensity(slot: dict[str, Any] | None) -> float | None:
|
|
if not slot or not _slot_expression_enabled(slot):
|
|
return None
|
|
intensity = _normalize_slot_expression_intensity(slot.get("expression_intensity"))
|
|
return intensity if intensity >= 0 else None
|
|
|
|
|
|
def _slot_expression_intensity_for_phase(slot: dict[str, Any] | None, phase: str = "") -> float | None:
|
|
if not slot or not _slot_expression_enabled(slot):
|
|
return None
|
|
phase_key = f"{phase}_expression_intensity" if phase in ("softcore", "hardcore") else ""
|
|
if phase_key:
|
|
intensity = _normalize_slot_expression_intensity(slot.get(phase_key))
|
|
if intensity >= 0:
|
|
return intensity
|
|
return _slot_expression_intensity(slot)
|
|
|
|
|
|
def _normalize_slot_seed(value: Any) -> int:
|
|
try:
|
|
seed = int(value)
|
|
except (TypeError, ValueError):
|
|
return -1
|
|
if seed < 0:
|
|
return -1
|
|
return min(seed, CHARACTER_SLOT_SEED_MAX)
|
|
|
|
|
|
def _slot_seed(slot: dict[str, Any] | None) -> int:
|
|
if not slot:
|
|
return -1
|
|
return _normalize_slot_seed(slot.get("slot_seed"))
|
|
|
|
|
|
def _slot_seeded_rng(slot: dict[str, Any] | None, salt: int) -> random.Random | None:
|
|
seed = _slot_seed(slot)
|
|
if seed < 0:
|
|
return None
|
|
return random.Random(_row_seed(seed, 1, salt))
|
|
|
|
|
|
def _slot_context_rng(slot: dict[str, Any], fallback_rng: random.Random) -> random.Random:
|
|
return _slot_seeded_rng(slot, 701) or fallback_rng
|
|
|
|
|
|
def _slot_effective_figure(
|
|
slot: dict[str, Any],
|
|
subject_type: str,
|
|
fallback_figure: str,
|
|
) -> str:
|
|
raw_figure = str(slot.get("figure") or "random").strip()
|
|
if raw_figure in ("curvy", "balanced", "bombshell"):
|
|
return raw_figure
|
|
seeded_rng = _slot_seeded_rng(slot, 709)
|
|
if subject_type == "woman" and seeded_rng is not None:
|
|
return g.choose(seeded_rng, ["curvy", "balanced", "bombshell"])
|
|
return fallback_figure
|
|
|
|
|
|
def _mean(values: list[float]) -> float:
|
|
return sum(values) / len(values)
|
|
|
|
|
|
def _cast_expression_intensity_override(
|
|
fallback: float,
|
|
label_map: dict[str, dict[str, Any]],
|
|
women_count: int,
|
|
men_count: int,
|
|
expression_phase: str = "",
|
|
) -> tuple[float | None, str]:
|
|
groups: list[tuple[str, list[str]]] = [
|
|
("women", [f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))]),
|
|
("men", [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]),
|
|
]
|
|
all_values: list[float] = []
|
|
matching_slots: list[dict[str, Any]] = []
|
|
for group_name, labels in groups:
|
|
values: list[float] = []
|
|
value_labels: list[str] = []
|
|
for label in labels:
|
|
slot = label_map.get(label)
|
|
if _slot_is_pov(slot):
|
|
continue
|
|
if slot:
|
|
matching_slots.append(slot)
|
|
value = _slot_expression_intensity_for_phase(slot, expression_phase)
|
|
if value is not None:
|
|
values.append(value)
|
|
value_labels.append(label)
|
|
all_values.append(value)
|
|
if values:
|
|
if len(values) == 1:
|
|
return values[0], f"character_slot:{value_labels[0]}"
|
|
return _mean(values), f"character_slots:{group_name}"
|
|
if all_values:
|
|
return _mean(all_values), "character_slots:cast"
|
|
if matching_slots and all(not _slot_expression_enabled(slot) for slot in matching_slots):
|
|
return None, "character_slots:disabled"
|
|
return fallback, "input"
|
|
|
|
|
|
def _character_expression_entries(
|
|
rng: random.Random,
|
|
expression_pool: list[Any],
|
|
fallback_intensity: float,
|
|
label_map: dict[str, dict[str, Any]],
|
|
women_count: int,
|
|
men_count: int,
|
|
expression_phase: str = "",
|
|
) -> list[str]:
|
|
labels = [
|
|
*[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))],
|
|
*[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))],
|
|
]
|
|
expressions: list[str] = []
|
|
used: set[str] = set()
|
|
for label in labels:
|
|
slot = label_map.get(label)
|
|
if not slot:
|
|
continue
|
|
if _slot_is_pov(slot):
|
|
continue
|
|
if not _slot_expression_enabled(slot):
|
|
continue
|
|
intensity = _slot_expression_intensity_for_phase(slot, expression_phase)
|
|
if intensity is None:
|
|
intensity = fallback_intensity
|
|
entries = _compatible_entries(
|
|
_expression_entries_for_intensity(expression_pool, intensity),
|
|
women_count,
|
|
men_count,
|
|
)
|
|
if not entries:
|
|
continue
|
|
choice = ""
|
|
for _attempt in range(5):
|
|
candidate = _choose_text(rng, entries)
|
|
if candidate not in used:
|
|
choice = candidate
|
|
break
|
|
if not choice:
|
|
choice = _choose_text(rng, entries)
|
|
used.add(choice)
|
|
expressions.append(f"{label} has {choice}")
|
|
return expressions
|
|
|
|
|
|
def _sanitize_character_expression_text_for_action(
|
|
expression_text: str,
|
|
role_graph: Any,
|
|
item: Any,
|
|
axis_values: Any = None,
|
|
) -> str:
|
|
text = str(expression_text or "").strip()
|
|
if not text:
|
|
return ""
|
|
context = " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
role_graph,
|
|
item,
|
|
*((axis_values or {}).values() if isinstance(axis_values, dict) else ()),
|
|
)
|
|
)
|
|
woman_active_outercourse = (
|
|
re.search(r"\bwoman [a-z]\b", context)
|
|
and re.search(r"\bman [a-z]\b", context)
|
|
and any(
|
|
term in context
|
|
for term in (
|
|
"boobjob",
|
|
"titjob",
|
|
"breast sex",
|
|
"breasts tightly",
|
|
"testicle",
|
|
"balls-licking",
|
|
"balls licking",
|
|
"penis-licking",
|
|
"penis licking",
|
|
"handjob",
|
|
"hand job",
|
|
"footjob",
|
|
)
|
|
)
|
|
)
|
|
woman_gives_oral = (
|
|
re.search(r"\bwoman [a-z]\b", context)
|
|
and re.search(r"\bman [a-z]\b", context)
|
|
and any(
|
|
term in context
|
|
for term in (
|
|
"takes man",
|
|
"penis in her mouth",
|
|
"mouth at penis level",
|
|
"fellatio",
|
|
"blowjob",
|
|
"deepthroat",
|
|
"penis sucking",
|
|
"lips wrapped",
|
|
)
|
|
)
|
|
)
|
|
man_gives_oral = (
|
|
re.search(r"\bwoman [a-z]\b", context)
|
|
and re.search(r"\bman [a-z]\b", context)
|
|
and any(
|
|
term in context
|
|
for term in (
|
|
"mouth on her pussy",
|
|
"mouth on woman",
|
|
"mouth pressed to her pussy",
|
|
"cunnilingus",
|
|
"pussy licking",
|
|
"tongue on pussy",
|
|
)
|
|
)
|
|
)
|
|
mouth_expression_terms = ("mouth", "oral", "tongue", "lips", "gagging", "saliva")
|
|
clauses = [clause.strip() for clause in text.split(";") if clause.strip()]
|
|
if woman_active_outercourse:
|
|
clauses = [clause for clause in clauses if not re.match(r"^Man [A-Z] has\b", clause)]
|
|
if woman_gives_oral:
|
|
clauses = [
|
|
clause
|
|
for clause in clauses
|
|
if not (
|
|
re.match(r"^Man [A-Z] has\b", clause)
|
|
and any(term in clause.lower() for term in mouth_expression_terms)
|
|
)
|
|
]
|
|
if man_gives_oral:
|
|
clauses = [
|
|
clause
|
|
for clause in clauses
|
|
if not (
|
|
re.match(r"^Woman [A-Z] has\b", clause)
|
|
and any(term in clause.lower() for term in mouth_expression_terms)
|
|
)
|
|
]
|
|
return "; ".join(clauses)
|
|
|
|
|
|
def _descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str:
|
|
detail = _normalize_descriptor_detail(descriptor_detail)
|
|
if detail != "auto":
|
|
return detail
|
|
return "compact" if str(subject or "").strip().lower() == "man" else "full"
|
|
|
|
|
|
def _descriptor_from_parts(
|
|
subject: Any,
|
|
age: Any,
|
|
body_phrase: Any,
|
|
skin: Any,
|
|
hair: Any,
|
|
eyes: Any,
|
|
descriptor_detail: Any = "auto",
|
|
) -> str:
|
|
subject = str(subject or "person").strip() or "person"
|
|
age_text = " ".join(str(age or "").strip().split())
|
|
age_text = age_text.removesuffix(" adults").removesuffix(" adult").strip()
|
|
if age_text in ("adult", "adults"):
|
|
age_text = ""
|
|
subject_phrase = f"{age_text} adult {subject}".strip() if age_text else f"adult {subject}"
|
|
detail = _descriptor_detail_for_subject(subject, descriptor_detail)
|
|
detail_map = {
|
|
"minimal": (body_phrase,),
|
|
"compact": (body_phrase, skin),
|
|
"medium": (body_phrase, skin, hair),
|
|
"full": (body_phrase, skin, hair, eyes),
|
|
}
|
|
pieces = [subject_phrase, *detail_map.get(detail, detail_map["full"])]
|
|
return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip())
|
|
|
|
|
|
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:
|
|
return normalize_ethnicity_filter(value, "random", allow_random=True)
|
|
|
|
|
|
def _normalize_hair_choice(value: Any, choices: list[str]) -> str:
|
|
text = str(value or "random").strip().lower().replace("-", "_").replace(" ", "_")
|
|
return text if text in choices else "random"
|
|
|
|
|
|
def _infer_hair_color_key(text: Any) -> str:
|
|
value = str(text or "").lower()
|
|
checks = (
|
|
("platinum_blonde", ("platinum-blonde", "platinum blonde", "platinum")),
|
|
("strawberry_blonde", ("strawberry-blonde", "strawberry blonde")),
|
|
("honey_blonde", ("honey-blonde", "honey blonde")),
|
|
("ash_blonde", ("ash-blonde", "ash blonde")),
|
|
("dark_blonde", ("dark-blonde", "dark blonde")),
|
|
(
|
|
"blonde",
|
|
(
|
|
"light-blonde",
|
|
"light blonde",
|
|
"blonde",
|
|
"flaxen",
|
|
"wheat-blonde",
|
|
"wheat blonde",
|
|
"beige-blonde",
|
|
"beige blonde",
|
|
"sandy-blonde",
|
|
"sandy blonde",
|
|
),
|
|
),
|
|
("silver_gray", ("silver-gray", "silver grey", "silver", "gray", "grey")),
|
|
("dark_brown", ("dark-brown", "dark brown", "espresso")),
|
|
("chestnut", ("chestnut",)),
|
|
("auburn", ("auburn",)),
|
|
("copper", ("copper",)),
|
|
("red", ("red hair", "redhead")),
|
|
("black", ("black",)),
|
|
("brown", ("brown", "brunette", "caramel")),
|
|
("white", ("white",)),
|
|
)
|
|
for key, tokens in checks:
|
|
if any(token in value for token in tokens):
|
|
return key
|
|
return "random"
|
|
|
|
|
|
def _infer_hair_length_key(text: Any) -> str:
|
|
value = str(text or "").lower()
|
|
if any(token in value for token in ("very long", "waist-length", "hip-length")):
|
|
return "very_long"
|
|
if "long" in value:
|
|
return "long"
|
|
if "shoulder-length" in value or "shoulder length" in value:
|
|
return "shoulder_length"
|
|
if "medium-length" in value or "medium length" in value:
|
|
return "medium"
|
|
if any(token in value for token in ("bob", "lob")):
|
|
return "bob_lob"
|
|
if any(token in value for token in ("pixie", "short", "cropped", "tapered")):
|
|
return "short"
|
|
if any(token in value for token in ("bun", "updo")):
|
|
return "updo"
|
|
return "random"
|
|
|
|
|
|
def _infer_hair_style_key(text: Any) -> str:
|
|
value = str(text or "").lower()
|
|
checks = (
|
|
("pixie_cut", ("pixie",)),
|
|
("messy_bun", ("messy bun",)),
|
|
("bun", ("bun", "updo")),
|
|
("ponytail", ("ponytail",)),
|
|
("braids", ("braids", "box braids", "cornrow")),
|
|
("braid", ("braid",)),
|
|
("locs", ("locs", "dreadlocks")),
|
|
("twists", ("twists",)),
|
|
("afro", ("afro",)),
|
|
("natural_curls", ("natural curls", "natural coils", "coils")),
|
|
("tight_curls", ("tight curls", "tight coils")),
|
|
("curls", ("curls", "curly")),
|
|
("loose_waves", ("loose waves",)),
|
|
("waves", ("waves", "wavy")),
|
|
("lob", ("lob",)),
|
|
("bob", ("bob",)),
|
|
("shag", ("shag",)),
|
|
("wet_hair", ("wet hair", "damp hair")),
|
|
("slicked_back", ("slicked-back", "slicked back")),
|
|
("straight", ("straight", "sleek")),
|
|
)
|
|
for key, tokens in checks:
|
|
if any(token in value for token in tokens):
|
|
return key
|
|
return "random"
|
|
|
|
|
|
def _choose_hair_key(rng: random.Random, choices: list[str]) -> str:
|
|
pool = [choice for choice in choices if choice != "random"]
|
|
return g.choose(rng, pool) if pool else "random"
|
|
|
|
|
|
def _normalize_hair_values(values: Any, choices: list[str]) -> list[str]:
|
|
if isinstance(values, str):
|
|
raw_values = [part.strip() for part in re.split(r"[,;\n]+", values) if part.strip()]
|
|
elif isinstance(values, (list, tuple, set)):
|
|
raw_values = list(values)
|
|
else:
|
|
raw_values = []
|
|
normalized: list[str] = []
|
|
for value in raw_values:
|
|
key = _normalize_hair_choice(value, choices)
|
|
if key != "random" and key not in normalized:
|
|
normalized.append(key)
|
|
return normalized
|
|
|
|
|
|
def _empty_hair_config() -> dict[str, Any]:
|
|
return {"config_type": "hair_characteristics", "colors": [], "lengths": [], "styles": []}
|
|
|
|
|
|
def _parse_hair_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
if not value:
|
|
return _empty_hair_config()
|
|
if isinstance(value, dict):
|
|
raw = value
|
|
else:
|
|
try:
|
|
raw = json.loads(str(value))
|
|
except json.JSONDecodeError:
|
|
return _empty_hair_config()
|
|
if not isinstance(raw, dict):
|
|
return _empty_hair_config()
|
|
return {
|
|
"config_type": "hair_characteristics",
|
|
"colors": _normalize_hair_values(raw.get("colors"), CHARACTER_HAIR_COLOR_CHOICES),
|
|
"lengths": _normalize_hair_values(raw.get("lengths"), CHARACTER_HAIR_LENGTH_CHOICES),
|
|
"styles": _normalize_hair_values(raw.get("styles"), CHARACTER_HAIR_STYLE_CHOICES),
|
|
}
|
|
|
|
|
|
def _hair_config_summary(config: dict[str, Any]) -> str:
|
|
parts = []
|
|
for label, key in (("colors", "colors"), ("lengths", "lengths"), ("styles", "styles")):
|
|
values = config.get(key) or []
|
|
if values:
|
|
parts.append(f"{label}={','.join(values)}")
|
|
return "; ".join(parts) if parts else "hair unrestricted"
|
|
|
|
|
|
def build_hair_config_json(
|
|
hair_config: str | dict[str, Any] | None = "",
|
|
axis: str = "color",
|
|
selected_values: list[str] | tuple[str, ...] | str | None = None,
|
|
combine_mode: str = "replace_axis",
|
|
) -> str:
|
|
config = _parse_hair_config(hair_config)
|
|
axis_key = {"color": "colors", "length": "lengths", "style": "styles"}.get(str(axis or "").strip().lower())
|
|
choice_map = {
|
|
"colors": CHARACTER_HAIR_COLOR_CHOICES,
|
|
"lengths": CHARACTER_HAIR_LENGTH_CHOICES,
|
|
"styles": CHARACTER_HAIR_STYLE_CHOICES,
|
|
}
|
|
if axis_key:
|
|
values = _normalize_hair_values(selected_values, choice_map[axis_key])
|
|
if combine_mode == "add_to_axis":
|
|
existing = list(config.get(axis_key) or [])
|
|
for value in values:
|
|
if value not in existing:
|
|
existing.append(value)
|
|
config[axis_key] = existing
|
|
else:
|
|
config[axis_key] = values
|
|
config["summary"] = _hair_config_summary(config)
|
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
|
|
|
|
|
def _hair_color_text(key: str) -> str:
|
|
return {
|
|
"black": "black",
|
|
"brown": "brown",
|
|
"dark_brown": "dark-brown",
|
|
"chestnut": "chestnut",
|
|
"auburn": "auburn",
|
|
"copper": "copper",
|
|
"red": "red",
|
|
"blonde": "blonde",
|
|
"platinum_blonde": "platinum-blonde",
|
|
"ash_blonde": "ash-blonde",
|
|
"honey_blonde": "honey-blonde",
|
|
"strawberry_blonde": "strawberry-blonde",
|
|
"dark_blonde": "dark-blonde",
|
|
"silver_gray": "silver-gray",
|
|
"white": "white",
|
|
}.get(key, "brown")
|
|
|
|
|
|
def _hair_length_text(key: str) -> str:
|
|
return {
|
|
"very_short": "very short",
|
|
"short": "short",
|
|
"bob_lob": "",
|
|
"shoulder_length": "shoulder-length",
|
|
"medium": "medium-length",
|
|
"long": "long",
|
|
"very_long": "very long",
|
|
"updo": "",
|
|
}.get(key, "")
|
|
|
|
|
|
def _hair_phrase_from_parts(color_key: str, length_key: str, style_key: str) -> str:
|
|
color = _hair_color_text(color_key)
|
|
length = _hair_length_text(length_key)
|
|
prefix = " ".join(part for part in (length, color) if part)
|
|
if style_key == "pixie_cut":
|
|
return f"short {color} pixie cut"
|
|
if style_key == "bob":
|
|
return f"{color} bob" if length_key in ("random", "bob_lob", "short") else f"{prefix} bob"
|
|
if style_key == "lob":
|
|
return f"shoulder-length {color} lob" if length_key in ("random", "bob_lob") else f"{prefix} lob"
|
|
if style_key == "shag":
|
|
return f"{prefix or color} shag"
|
|
if style_key == "ponytail":
|
|
return f"{prefix or color} ponytail"
|
|
if style_key == "braid":
|
|
return f"{prefix or color} braid"
|
|
if style_key == "braids":
|
|
return f"{prefix or color} braids"
|
|
if style_key == "bun":
|
|
return f"{prefix} hair in a bun" if length else f"{color} bun"
|
|
if style_key == "messy_bun":
|
|
return f"{prefix} hair in a messy bun" if length else f"messy {color} bun"
|
|
if style_key == "locs":
|
|
return f"{prefix or color} locs"
|
|
if style_key == "twists":
|
|
return f"{prefix or color} twists"
|
|
if style_key == "afro":
|
|
return f"{color} afro"
|
|
if style_key == "natural_curls":
|
|
return f"{prefix or color} natural curls"
|
|
if style_key == "wet_hair":
|
|
return f"{prefix or color} wet hair"
|
|
if style_key == "slicked_back":
|
|
return f"slicked-back {color} hair"
|
|
if style_key == "straight":
|
|
return f"{prefix or color} straight hair"
|
|
if style_key == "loose_waves":
|
|
return f"{prefix or color} loose waves"
|
|
if style_key == "tight_curls":
|
|
return f"{prefix or color} tight curls"
|
|
if style_key == "curls":
|
|
return f"{prefix or color} curls"
|
|
return f"{prefix or color} waves"
|
|
|
|
|
|
def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str:
|
|
hair_config = _parse_hair_config(slot.get("hair_config"))
|
|
color_choice = _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES)
|
|
length_choice = _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES)
|
|
style_choice = _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES)
|
|
color_options = hair_config.get("colors") or []
|
|
length_options = hair_config.get("lengths") or []
|
|
style_options = hair_config.get("styles") or []
|
|
if (
|
|
color_choice == "random"
|
|
and length_choice == "random"
|
|
and style_choice == "random"
|
|
and not color_options
|
|
and not length_options
|
|
and not style_options
|
|
):
|
|
return ""
|
|
if color_choice != "random":
|
|
color_key = color_choice
|
|
elif color_options:
|
|
color_key = g.choose(rng, color_options)
|
|
else:
|
|
color_key = _infer_hair_color_key(base_hair)
|
|
|
|
if length_choice != "random":
|
|
length_key = length_choice
|
|
elif length_options:
|
|
length_key = g.choose(rng, length_options)
|
|
else:
|
|
length_key = _infer_hair_length_key(base_hair)
|
|
|
|
if style_choice != "random":
|
|
style_key = style_choice
|
|
elif style_options:
|
|
style_key = g.choose(rng, style_options)
|
|
else:
|
|
style_key = _infer_hair_style_key(base_hair)
|
|
if color_key == "random":
|
|
color_key = _choose_hair_key(rng, CHARACTER_HAIR_COLOR_CHOICES)
|
|
if length_key == "random":
|
|
length_key = _choose_hair_key(rng, CHARACTER_HAIR_LENGTH_CHOICES)
|
|
if style_key == "random":
|
|
style_key = _choose_hair_key(rng, CHARACTER_HAIR_STYLE_CHOICES)
|
|
if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"):
|
|
style_key = g.choose(rng, ["ponytail", "braid", "bun", "messy_bun"])
|
|
return _hair_phrase_from_parts(color_key, length_key, style_key)
|
|
|
|
|
|
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"
|
|
|
|
manual_config = _parse_character_manual_config(slot.get("manual") or slot.get("manual_config"))
|
|
|
|
raw_age = str(slot.get("age") or "random")
|
|
raw_manual_age = str(slot.get("manual_age") or "").strip()
|
|
if not raw_manual_age and manual_config.get("manual_age"):
|
|
raw_manual_age = manual_config["manual_age"]
|
|
if raw_age.lower() in CHARACTER_RANDOM_TOKENS:
|
|
raw_age = "manual"
|
|
age = _slot_manual_or_choice(raw_age, raw_manual_age)
|
|
|
|
raw_body = str(slot.get("body") or "random")
|
|
raw_manual_body = str(slot.get("manual_body") or "").strip()
|
|
if not raw_manual_body and manual_config.get("manual_body"):
|
|
raw_manual_body = manual_config["manual_body"]
|
|
if raw_body.lower() in CHARACTER_RANDOM_TOKENS:
|
|
raw_body = "manual"
|
|
body = _slot_manual_or_choice(raw_body, raw_manual_body)
|
|
figure = str(slot.get("figure") or "random").strip()
|
|
if figure not in character_figure_choices():
|
|
figure = "random"
|
|
|
|
def manual_fallback(field: str) -> str:
|
|
direct = _slot_value(slot.get(field))
|
|
return direct or manual_config.get(field, "")
|
|
|
|
normalized = {
|
|
"profile_type": "character_slot",
|
|
"subject_type": subject_type,
|
|
"label": label,
|
|
"slot_seed": _normalize_slot_seed(slot.get("slot_seed")),
|
|
"age": age,
|
|
"ethnicity": _normalize_slot_ethnicity(slot.get("ethnicity")),
|
|
"figure": figure,
|
|
"body": body,
|
|
"body_phrase": manual_fallback("body_phrase"),
|
|
"skin": manual_fallback("skin"),
|
|
"hair": manual_fallback("hair"),
|
|
"manual": manual_config,
|
|
"characteristics": (
|
|
slot.get("characteristics")
|
|
if isinstance(slot.get("characteristics"), dict)
|
|
else _slot_value(slot.get("characteristics") or slot.get("characteristics_config"))
|
|
),
|
|
"hair_config": (
|
|
slot.get("hair_config")
|
|
if isinstance(slot.get("hair_config"), dict)
|
|
else _slot_value(slot.get("hair_config"))
|
|
),
|
|
"hair_color": _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES),
|
|
"hair_length": _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES),
|
|
"hair_style": _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES),
|
|
"eyes": manual_fallback("eyes"),
|
|
"descriptor_detail": _normalize_descriptor_detail(slot.get("descriptor_detail")),
|
|
"presence_mode": _normalize_presence_mode(slot.get("presence_mode"), subject_type),
|
|
"softcore_outfit": manual_fallback("softcore_outfit"),
|
|
"hardcore_clothing": (
|
|
_slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit"))
|
|
or manual_config.get("hardcore_clothing", "")
|
|
),
|
|
"expression_enabled": not _is_false(slot.get("expression_enabled", True)),
|
|
"expression_intensity": _normalize_slot_expression_intensity(slot.get("expression_intensity")),
|
|
"softcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("softcore_expression_intensity")),
|
|
"hardcore_expression_intensity": _normalize_slot_expression_intensity(slot.get("hardcore_expression_intensity")),
|
|
}
|
|
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"seed={slot.get('slot_seed')}" if _slot_seed(slot) >= 0 else "",
|
|
f"age={slot.get('age', 'random')}",
|
|
f"ethnicity={slot.get('ethnicity', 'random')}",
|
|
f"figure={slot.get('figure', 'random')}",
|
|
f"body={slot.get('body', 'random')}",
|
|
f"detail={slot.get('descriptor_detail', 'auto')}",
|
|
]
|
|
parts = [part for part in parts if part]
|
|
if _slot_is_pov(slot):
|
|
parts.append("presence=pov")
|
|
if not _slot_expression_enabled(slot):
|
|
parts.append("expression=disabled")
|
|
else:
|
|
expression_intensity = _slot_expression_intensity(slot)
|
|
if expression_intensity is not None:
|
|
parts.append(f"expression={expression_intensity:.2f}")
|
|
softcore_expression_intensity = _slot_expression_intensity_for_phase(slot, "softcore")
|
|
hardcore_expression_intensity = _slot_expression_intensity_for_phase(slot, "hardcore")
|
|
if softcore_expression_intensity is not None and softcore_expression_intensity != expression_intensity:
|
|
parts.append(f"soft_expr={softcore_expression_intensity:.2f}")
|
|
if hardcore_expression_intensity is not None and hardcore_expression_intensity != expression_intensity:
|
|
parts.append(f"hard_expr={hardcore_expression_intensity:.2f}")
|
|
if slot.get("softcore_outfit"):
|
|
parts.append(f"soft_outfit={slot['softcore_outfit']}")
|
|
if slot.get("hardcore_clothing"):
|
|
parts.append(f"hard_clothing={slot['hardcore_clothing']}")
|
|
characteristics = _parse_characteristics_config(slot.get("characteristics"))
|
|
characteristics_summary = _characteristics_summary(characteristics)
|
|
if characteristics_summary != "characteristics unrestricted":
|
|
parts.append(f"characteristics={characteristics_summary}")
|
|
hair_config = _parse_hair_config(slot.get("hair_config"))
|
|
hair_config_summary = _hair_config_summary(hair_config)
|
|
if hair_config_summary != "hair unrestricted":
|
|
parts.append(f"hair={hair_config_summary}")
|
|
for key in ("hair_color", "hair_length", "hair_style"):
|
|
value = slot.get(key)
|
|
if value and value != "random":
|
|
parts.append(f"{key}={value}")
|
|
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",
|
|
slot_seed: int = -1,
|
|
age: str = "random",
|
|
manual_age: str = "",
|
|
manual: str | dict[str, Any] | None = "",
|
|
ethnicity: str = "random",
|
|
figure: str = "random",
|
|
body: str = "random",
|
|
manual_body: str = "",
|
|
body_phrase: str = "",
|
|
skin: str = "",
|
|
hair: str = "",
|
|
characteristics: str | dict[str, Any] | None = "",
|
|
hair_config: str | dict[str, Any] | None = "",
|
|
hair_color: str = "random",
|
|
hair_length: str = "random",
|
|
hair_style: str = "random",
|
|
eyes: str = "",
|
|
descriptor_detail: str = "auto",
|
|
expression_enabled: bool = True,
|
|
expression_intensity: float = -1.0,
|
|
enabled: bool = True,
|
|
character_cast: str | dict[str, Any] | list[Any] | None = "",
|
|
presence_mode: str = "visible",
|
|
softcore_expression_intensity: float = -1.0,
|
|
hardcore_expression_intensity: float = -1.0,
|
|
softcore_outfit: str = "",
|
|
hardcore_clothing: str = "",
|
|
) -> dict[str, str]:
|
|
existing_slots = _parse_character_cast(character_cast)
|
|
slot = _normalize_character_slot(
|
|
{
|
|
"subject_type": subject_type,
|
|
"label": label,
|
|
"slot_seed": slot_seed,
|
|
"age": age,
|
|
"manual_age": manual_age,
|
|
"manual": manual,
|
|
"ethnicity": ethnicity,
|
|
"figure": figure,
|
|
"body": body,
|
|
"manual_body": manual_body,
|
|
"body_phrase": body_phrase,
|
|
"skin": skin,
|
|
"hair": hair,
|
|
"characteristics": characteristics,
|
|
"hair_config": hair_config,
|
|
"hair_color": hair_color,
|
|
"hair_length": hair_length,
|
|
"hair_style": hair_style,
|
|
"eyes": eyes,
|
|
"descriptor_detail": descriptor_detail,
|
|
"presence_mode": presence_mode,
|
|
"softcore_outfit": softcore_outfit,
|
|
"hardcore_clothing": hardcore_clothing,
|
|
"expression_enabled": expression_enabled,
|
|
"expression_intensity": expression_intensity,
|
|
"softcore_expression_intensity": softcore_expression_intensity,
|
|
"hardcore_expression_intensity": hardcore_expression_intensity,
|
|
}
|
|
)
|
|
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 _pov_character_labels(
|
|
label_map: dict[str, dict[str, Any]],
|
|
men_count: int | None = None,
|
|
) -> list[str]:
|
|
if men_count is None:
|
|
labels = sorted(label for label in label_map if label.startswith("Man "))
|
|
else:
|
|
labels = [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]
|
|
return [label for label in labels if _slot_is_pov(label_map.get(label))]
|
|
|
|
|
|
def _pov_text_with_viewer(text: Any, pov_labels: list[str]) -> str:
|
|
rendered = str(text or "").strip()
|
|
if not rendered or not pov_labels:
|
|
return rendered
|
|
for label in sorted(pov_labels, key=len, reverse=True):
|
|
escaped = re.escape(label)
|
|
rendered = re.sub(rf"\b{escaped}'s\b", "the POV viewer's", rendered)
|
|
rendered = re.sub(rf"\b{escaped}\b", "the POV viewer", rendered)
|
|
rendered = re.sub(r"\bthe POV viewer is positioned\b", "the POV camera is positioned", rendered, flags=re.IGNORECASE)
|
|
return _clean_prompt_punctuation(rendered)
|
|
|
|
|
|
def _pov_role_graph_prompt(role_graph: Any, pov_labels: list[str]) -> str:
|
|
role_graph_text = str(role_graph or "").strip()
|
|
if not role_graph_text or not pov_labels:
|
|
return role_graph_text
|
|
viewer_text = _pov_text_with_viewer(role_graph_text, pov_labels)
|
|
label_text = ", ".join(pov_labels)
|
|
return f"First-person POV from {label_text}; {viewer_text}"
|
|
|
|
|
|
def _pov_prompt_directive(pov_labels: list[str]) -> str:
|
|
if not pov_labels:
|
|
return ""
|
|
label_text = ", ".join(pov_labels)
|
|
return (
|
|
f"POV participant: {label_text} is the first-person camera viewpoint; "
|
|
"he remains the off-camera viewpoint, represented by foreground hands, body position, or camera perspective cues when needed."
|
|
)
|
|
|
|
|
|
def _pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str:
|
|
text = str(composition or "").strip()
|
|
if not text or not pov_labels:
|
|
return text
|
|
text = re.sub(r"\ball participants visible\b", "visible partners readable", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\ball adult bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\ball bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\ball three bodies readable\b", "visible partner bodies readable", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\bwide group-sex composition\b", "first-person group-sex POV composition", text, flags=re.IGNORECASE)
|
|
if "pov" not in text.lower() and "first-person" not in text.lower():
|
|
text = f"{text}, adapted for first-person POV with the POV participant kept off-camera"
|
|
return _clean_prompt_punctuation(text)
|
|
|
|
|
|
def _body_exposure_scene_text(scene: Any) -> str:
|
|
text = str(scene or "").strip()
|
|
if not text:
|
|
return ""
|
|
replacements = (
|
|
(r",?\s*\bscattered (?:clothes|clothing)\b", ""),
|
|
(r",?\s*\bfloor clothes\b", ""),
|
|
(r"\bclothes scattered\b", "soft floor shadows"),
|
|
(r",?\s*\bscattered lingerie\b", ""),
|
|
(r",?\s*\blingerie visible nearby\b", ""),
|
|
(r"\boutfit racks\b", "mirror shelves"),
|
|
(r"\bcostume racks\b", "mirror shelves"),
|
|
(r"\bhanging outfits\b", "hanging fabric"),
|
|
(r"\bclothing hooks\b", "wall hooks"),
|
|
(r"\boutfit-check\b", "creator-shot"),
|
|
(r"\boutfit framing\b", "body framing"),
|
|
(r"\bfull outfits\b", "full bodies"),
|
|
(r"\bcoordinated outfits\b", "coordinated posing"),
|
|
)
|
|
for pattern, replacement in replacements:
|
|
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\bwith,\s*", "with ", text, flags=re.IGNORECASE)
|
|
text = re.sub(r",\s*,", ",", text)
|
|
return _clean_prompt_punctuation(text)
|
|
|
|
|
|
def _slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
|
|
if not slot:
|
|
return ""
|
|
outfit = _slot_value(slot.get("softcore_outfit"))
|
|
if outfit:
|
|
return outfit
|
|
if rng is None:
|
|
return ""
|
|
return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "softcore_outfits", rng)
|
|
|
|
|
|
def _slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
|
|
if not slot:
|
|
return ""
|
|
clothing = _slot_value(slot.get("hardcore_clothing"))
|
|
if clothing:
|
|
return clothing
|
|
if rng is None:
|
|
return ""
|
|
return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "hardcore_clothing", rng)
|
|
|
|
|
|
def _softcore_outfit_sentence(label: str, outfit: str) -> str:
|
|
outfit = str(outfit or "").strip()
|
|
if not outfit:
|
|
return ""
|
|
lower = outfit.lower()
|
|
if lower.startswith(("wears ", "wearing ", "in ")):
|
|
return f"{label} {outfit}"
|
|
return f"{label} wears {outfit}"
|
|
|
|
|
|
def _hardcore_clothing_sentence(label: str, clothing: str) -> str:
|
|
clothing = str(clothing or "").strip().rstrip(".")
|
|
if not clothing:
|
|
return ""
|
|
lower = clothing.lower()
|
|
if lower.startswith(("fully nude", "nude")):
|
|
return f"{label}'s body is fully exposed, bare skin unobstructed"
|
|
if lower.startswith("partly nude"):
|
|
return f"{label}'s body is partly exposed"
|
|
if lower.startswith(("is ", "wears ", "wearing ", "keeps ", "has ", "with ")):
|
|
return f"{label} {clothing}"
|
|
return f"{label}'s clothing: {clothing}"
|
|
|
|
|
|
def _character_hardcore_clothing_entries(
|
|
label_map: dict[str, dict[str, Any]],
|
|
women_count: int,
|
|
men_count: int,
|
|
pov_labels: list[str] | None = None,
|
|
rng: random.Random | None = None,
|
|
) -> list[str]:
|
|
pov_set = set(pov_labels or [])
|
|
labels = [
|
|
*[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))],
|
|
*[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))],
|
|
]
|
|
entries: list[str] = []
|
|
for label in labels:
|
|
if label in pov_set:
|
|
continue
|
|
clothing = _slot_hardcore_clothing(label_map.get(label), rng)
|
|
sentence = _hardcore_clothing_sentence(label, clothing)
|
|
if sentence:
|
|
entries.append(sentence)
|
|
return entries
|
|
|
|
|
|
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_body = _slot_value(slot.get("body"))
|
|
effective_ethnicity = slot_ethnicity or ethnicity
|
|
effective_figure = _slot_effective_figure(slot, subject_type, figure)
|
|
effective_no_plus = bool(no_plus_women) and not slot_body
|
|
effective_no_black = bool(no_black) and not slot_ethnicity
|
|
appearance_rng = _slot_context_rng(slot, rng)
|
|
context = _appearance_for_subject(
|
|
appearance_rng,
|
|
subject_type,
|
|
effective_ethnicity,
|
|
effective_figure,
|
|
effective_no_plus,
|
|
effective_no_black,
|
|
)
|
|
|
|
characteristics = _parse_characteristics_config(slot.get("characteristics"))
|
|
age = _slot_value(slot.get("age")) or _characteristic_choice(characteristics, "ages", appearance_rng)
|
|
body_phrase = _slot_value(slot.get("body_phrase"))
|
|
if not slot_body:
|
|
slot_body = _characteristic_choice(characteristics, "bodies", appearance_rng)
|
|
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
|
|
skin_value = _slot_value(slot.get("skin"))
|
|
if skin_value:
|
|
context["skin"] = skin_value
|
|
eyes_value = _slot_value(slot.get("eyes"))
|
|
if not eyes_value:
|
|
eyes_value = _eye_phrase_from_key(_characteristic_choice(characteristics, "eyes", appearance_rng))
|
|
if eyes_value:
|
|
context["eyes"] = eyes_value
|
|
hair_value = _slot_value(slot.get("hair"))
|
|
if hair_value:
|
|
context["hair"] = hair_value
|
|
else:
|
|
hair_descriptor = _hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng)
|
|
if hair_descriptor:
|
|
context["hair"] = hair_descriptor
|
|
context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail"))
|
|
context["presence_mode"] = _normalize_presence_mode(slot.get("presence_mode"), subject_type)
|
|
context["expression_enabled"] = _slot_expression_enabled(slot)
|
|
expression_intensity = _slot_expression_intensity(slot)
|
|
if expression_intensity is not None:
|
|
context["expression_intensity"] = expression_intensity
|
|
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",
|
|
"descriptor_detail",
|
|
"presence_mode",
|
|
"expression_enabled",
|
|
"expression_intensity",
|
|
):
|
|
value = context.get(key)
|
|
if value is not None and 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)}"
|
|
if _slot_is_pov(label_map.get(label)):
|
|
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)}")
|
|
return descriptors, slots
|
|
|
|
|
|
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 _row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
slots = _parse_character_cast(character_slot)
|
|
if not slots:
|
|
return {}
|
|
slot = slots[-1]
|
|
if _slot_seed(slot) >= 0:
|
|
subject_type = str(slot.get("subject_type") or "woman")
|
|
return _context_from_character_slot(
|
|
random.Random(_row_seed(_slot_seed(slot), 1, 719)),
|
|
slot,
|
|
subject_type,
|
|
"any",
|
|
"curvy",
|
|
False,
|
|
False,
|
|
)
|
|
return slot
|
|
|
|
|
|
def _character_profile_descriptor(profile: dict[str, Any]) -> str:
|
|
subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip()
|
|
return _descriptor_from_parts(
|
|
subject,
|
|
profile.get("age"),
|
|
profile.get("body_phrase") or _body_phrase(profile.get("body"), profile.get("figure")),
|
|
profile.get("skin"),
|
|
profile.get("hair"),
|
|
profile.get("eyes"),
|
|
profile.get("descriptor_detail"),
|
|
)
|
|
|
|
|
|
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,
|
|
"descriptor_detail": _normalize_descriptor_detail(profile.get("descriptor_detail")),
|
|
}
|
|
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 = "",
|
|
character_slot: 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 == "character_slot":
|
|
row = _row_from_character_slot(character_slot or metadata_json)
|
|
raw_profile = {
|
|
"profile_name": profile_name,
|
|
"subject_type": row.get("subject_type") or subject_type,
|
|
"age": row.get("age") or age,
|
|
"body": row.get("body") 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,
|
|
"descriptor_detail": row.get("descriptor_detail") or "auto",
|
|
}
|
|
elif 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,
|
|
"descriptor_detail": row.get("descriptor_detail") or "auto",
|
|
}
|
|
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,
|
|
"descriptor_detail": "auto",
|
|
}
|
|
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 save_character_profile_payload(profile_name: str = "", profile_json: str | dict[str, Any] | None = "") -> dict[str, str]:
|
|
raw_profile = _load_json_object(profile_json, "profile_json")
|
|
if not raw_profile:
|
|
raise ValueError("No cached character profile is available to save.")
|
|
profile = _normalize_character_profile(raw_profile, profile_name or str(raw_profile.get("profile_name") or ""))
|
|
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")
|
|
return {
|
|
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
|
"profile_name": profile["profile_name"],
|
|
"descriptor": profile["descriptor"],
|
|
"saved_path": str(path),
|
|
"status": "saved",
|
|
}
|
|
|
|
|
|
def _empty_profile_result(status: str = "empty") -> dict[str, str]:
|
|
return {
|
|
"profile_json": "",
|
|
"profile_name": "",
|
|
"descriptor": "",
|
|
"saved_path": "",
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def _apply_character_profile_overrides(
|
|
profile: dict[str, Any],
|
|
override_subject_type: str = "",
|
|
override_age: str = "",
|
|
override_body: str = "",
|
|
override_body_phrase: str = "",
|
|
override_skin: str = "",
|
|
override_hair: str = "",
|
|
override_eyes: str = "",
|
|
override_figure: str = "",
|
|
override_descriptor_detail: str = "",
|
|
) -> dict[str, Any]:
|
|
updated = dict(profile)
|
|
subject_type = str(override_subject_type or "").strip()
|
|
if subject_type in ("woman", "man"):
|
|
updated["subject_type"] = subject_type
|
|
updated["subject"] = subject_type
|
|
updated["subject_phrase"] = subject_type
|
|
for key, value in (
|
|
("age", override_age),
|
|
("body", override_body),
|
|
("body_phrase", override_body_phrase),
|
|
("skin", override_skin),
|
|
("hair", override_hair),
|
|
("eyes", override_eyes),
|
|
("figure", override_figure),
|
|
):
|
|
text = str(value or "").strip()
|
|
if text:
|
|
updated[key] = text
|
|
descriptor_detail = str(override_descriptor_detail or "").strip()
|
|
if descriptor_detail and descriptor_detail != "keep_profile":
|
|
updated["descriptor_detail"] = _normalize_descriptor_detail(descriptor_detail)
|
|
if not str(updated.get("body_phrase") or "").strip():
|
|
updated["body_phrase"] = _body_phrase(updated.get("body"), updated.get("figure"))
|
|
updated["descriptor"] = _character_profile_descriptor(updated)
|
|
return updated
|
|
|
|
|
|
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 = "",
|
|
override_subject_type: str = "",
|
|
override_age: str = "",
|
|
override_body: str = "",
|
|
override_body_phrase: str = "",
|
|
override_skin: str = "",
|
|
override_hair: str = "",
|
|
override_eyes: str = "",
|
|
override_figure: str = "",
|
|
override_descriptor_detail: 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", ""))
|
|
profile = _apply_character_profile_overrides(
|
|
profile,
|
|
override_subject_type=override_subject_type,
|
|
override_age=override_age,
|
|
override_body=override_body,
|
|
override_body_phrase=override_body_phrase,
|
|
override_skin=override_skin,
|
|
override_hair=override_hair,
|
|
override_eyes=override_eyes,
|
|
override_figure=override_figure,
|
|
override_descriptor_detail=override_descriptor_detail,
|
|
)
|
|
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",
|
|
"descriptor_detail",
|
|
):
|
|
value = profile.get(key)
|
|
if value:
|
|
updated[key] = value
|
|
updated["subject"] = profile["subject_type"]
|
|
updated["subject_phrase"] = profile["subject_type"]
|
|
return updated, profile, "applied"
|
|
|
|
|
|
def _composition_prompt(composition: str) -> str:
|
|
composition = str(composition or "").strip()
|
|
if not composition:
|
|
return composition
|
|
lower = composition.lower()
|
|
if lower.startswith("vertical ") or " vertical " in lower or lower.endswith(" vertical"):
|
|
return composition
|
|
return f"vertical {composition}"
|
|
|
|
|
|
def _appearance_for_subject(
|
|
rng: random.Random,
|
|
subject_type: str,
|
|
ethnicity: str,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
) -> dict[str, str]:
|
|
if subject_type == "single_any":
|
|
subject_type = "woman" if rng.random() < 0.82 else "man"
|
|
|
|
if subject_type == "man":
|
|
men_ethnicity = ethnicity if ethnicity else "any"
|
|
subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity))
|
|
return {
|
|
"subject_type": "man",
|
|
"subject": subject,
|
|
"subject_phrase": subject,
|
|
"age": age,
|
|
"body": body,
|
|
"skin": skin,
|
|
"hair": hair,
|
|
"eyes": eyes,
|
|
"body_phrase": f"{body} figure",
|
|
}
|
|
|
|
subject, age, body, skin, hair, eyes = g.choose_woman(rng, ethnicity, no_plus_women, no_black)
|
|
figure_note = g.choose(rng, g.figure_pool(figure))
|
|
return {
|
|
"subject_type": "woman",
|
|
"subject": subject,
|
|
"subject_phrase": subject,
|
|
"age": age,
|
|
"body": body,
|
|
"skin": skin,
|
|
"hair": hair,
|
|
"eyes": eyes,
|
|
"body_phrase": _body_phrase(body, figure_note),
|
|
"figure": figure_note,
|
|
}
|
|
|
|
|
|
def _count_phrase(count: int, singular: str, plural: str) -> str:
|
|
words = {
|
|
0: "no",
|
|
1: "one",
|
|
2: "two",
|
|
3: "three",
|
|
4: "four",
|
|
5: "five",
|
|
6: "six",
|
|
7: "seven",
|
|
8: "eight",
|
|
9: "nine",
|
|
10: "ten",
|
|
11: "eleven",
|
|
12: "twelve",
|
|
}
|
|
label = singular if count == 1 else plural
|
|
return f"{words.get(count, str(count))} {label}"
|
|
|
|
|
|
def _configured_cast_context(women_count: int, men_count: int) -> dict[str, str]:
|
|
women_count = max(0, int(women_count))
|
|
men_count = max(0, int(men_count))
|
|
if women_count + men_count == 0:
|
|
women_count = 1
|
|
parts = []
|
|
if women_count:
|
|
parts.append(_count_phrase(women_count, "adult woman", "adult women"))
|
|
if men_count:
|
|
parts.append(_count_phrase(men_count, "adult man", "adult men"))
|
|
if len(parts) == 1:
|
|
subject_phrase = parts[0]
|
|
else:
|
|
subject_phrase = f"{parts[0]} and {parts[1]}"
|
|
person_count = women_count + men_count
|
|
if person_count == 1:
|
|
scene_kind = "solo adult sexual pose"
|
|
elif person_count == 2:
|
|
scene_kind = "adult couple sex scene"
|
|
elif person_count == 3:
|
|
scene_kind = "adult threesome sex scene"
|
|
else:
|
|
scene_kind = "adult group sex scene"
|
|
women_label = "woman" if women_count == 1 else "women"
|
|
men_label = "man" if men_count == 1 else "men"
|
|
cast_summary = f"{women_count} {women_label}, {men_count} {men_label}, {person_count} total adults"
|
|
return {
|
|
"subject_type": "configured_cast",
|
|
"subject": f"{women_count}w_{men_count}m_sex_scene",
|
|
"subject_phrase": subject_phrase,
|
|
"age": "21+ adults",
|
|
"body": "varied",
|
|
"skin": "",
|
|
"hair": "",
|
|
"eyes": "",
|
|
"body_phrase": "varied adult bodies",
|
|
"women_count": str(women_count),
|
|
"men_count": str(men_count),
|
|
"person_count": str(person_count),
|
|
"cast_summary": cast_summary,
|
|
"scene_kind": scene_kind,
|
|
}
|
|
|
|
|
|
def _couple_type_from_counts(
|
|
rng: random.Random,
|
|
women_count: int,
|
|
men_count: int,
|
|
) -> tuple[str, str, str, int, int]:
|
|
women_count = max(0, int(women_count))
|
|
men_count = max(0, int(men_count))
|
|
if women_count >= 2 and men_count == 0:
|
|
return "two women", "two women", "close affectionate couple pose", 2, 0
|
|
if men_count >= 2 and women_count == 0:
|
|
return "two men", "two men", "relaxed romantic couple pose", 0, 2
|
|
if women_count >= 1 and men_count >= 1:
|
|
return "woman and man", "a woman and a man", "playful date-night pose", 1, 1
|
|
|
|
primary_subject, subject_phrase, pose = g.choose(rng, g.COUPLE_TYPES)
|
|
if primary_subject == "two women":
|
|
return primary_subject, subject_phrase, pose, 2, 0
|
|
if primary_subject == "two men":
|
|
return primary_subject, subject_phrase, pose, 0, 2
|
|
return primary_subject, subject_phrase, pose, 1, 1
|
|
|
|
|
|
def _lettered(prefix: str, count: int) -> list[str]:
|
|
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
return [f"{prefix.capitalize()} {letters[index]}" for index in range(max(0, count))]
|
|
|
|
|
|
def _pick_distinct(rng: random.Random, items: list[str], count: int) -> list[str]:
|
|
if not items:
|
|
return []
|
|
if len(items) >= count:
|
|
return rng.sample(items, count)
|
|
picked = list(items)
|
|
while len(picked) < count:
|
|
picked.append(items[rng.randrange(len(items))])
|
|
return picked
|
|
|
|
|
|
def _participant_context(women_count: int, men_count: int) -> dict[str, list[str]]:
|
|
women = _lettered("woman", women_count)
|
|
men = _lettered("man", men_count)
|
|
return {"women": women, "men": men, "people": women + men}
|
|
|
|
|
|
def _role_graph(
|
|
rng: random.Random,
|
|
subcategory: dict[str, Any],
|
|
context: dict[str, str],
|
|
item_axis_values: dict[str, str] | None = None,
|
|
pov_labels: list[str] | None = None,
|
|
) -> str:
|
|
if context.get("subject_type") != "configured_cast":
|
|
return ""
|
|
women_count = int(context.get("women_count") or 0)
|
|
men_count = int(context.get("men_count") or 0)
|
|
people_count = women_count + men_count
|
|
if people_count <= 0:
|
|
return ""
|
|
|
|
participants = _participant_context(women_count, men_count)
|
|
women = participants["women"]
|
|
men = participants["men"]
|
|
people = participants["people"]
|
|
slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower()
|
|
item_text = " ".join((item_axis_values or {}).values()).lower()
|
|
pov_set = set(pov_labels or [])
|
|
|
|
def any_person(exclude: set[str] | None = None) -> str:
|
|
exclude = exclude or set()
|
|
pool = [person for person in people if person not in exclude] or people
|
|
return rng.choice(pool)
|
|
|
|
def any_woman(exclude: set[str] | None = None) -> str:
|
|
exclude = exclude or set()
|
|
pool = [person for person in women if person not in exclude] or [person for person in people if person not in exclude] or people
|
|
return rng.choice(pool)
|
|
|
|
def any_man(exclude: set[str] | None = None) -> str:
|
|
exclude = exclude or set()
|
|
pool = [person for person in men if person not in exclude] or [person for person in people if person not in exclude] or people
|
|
return rng.choice(pool)
|
|
|
|
def support_sentence(exclude: set[str]) -> str:
|
|
extras = [person for person in people if person not in exclude]
|
|
if not extras:
|
|
return ""
|
|
extra = rng.choice(extras)
|
|
actions = [
|
|
"kisses and grips the nearest body",
|
|
"holds hips open for the camera",
|
|
"touches breasts, thighs, and stomach",
|
|
"keeps one hand on a partner's ass",
|
|
"watches close and joins the body contact",
|
|
"presses in from the side with hands on skin",
|
|
]
|
|
return f" {extra} {rng.choice(actions)}."
|
|
|
|
def foreplay_position_graph(primary: str, partner: str) -> str:
|
|
text = " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
item_text,
|
|
*((item_axis_values or {}).values()),
|
|
)
|
|
)
|
|
if any(term in text for term in ("undressing", "removing clothing", "removing clothes", "pulling clothing", "sliding straps", "unbuttoning")):
|
|
return (
|
|
f"{primary} and {partner} stand close while {partner}'s hands pull clothing aside from {primary}'s body; "
|
|
f"{primary}'s exposed skin and the clothing being removed stay clearly visible."
|
|
)
|
|
if any(term in text for term in ("breast", "breasts", "nipple", "cupping breasts", "touching breasts")):
|
|
return (
|
|
f"{primary} and {partner} press their bodies close while {partner}'s hand cups {primary}'s breast; "
|
|
f"their faces stay close and the breast-touching gesture is clear."
|
|
)
|
|
if any(term in text for term in ("face", "cheek", "jaw", "chin", "hand on the cheek", "fingers under the chin")):
|
|
return (
|
|
f"{primary} and {partner} stand face-to-face at close range while one hand holds {primary}'s cheek and jaw; "
|
|
f"their lips are close and the face-touching gesture is clear."
|
|
)
|
|
if any(term in text for term in ("kiss", "kissing", "mouth-to-mouth", "lips pressed")):
|
|
return (
|
|
f"{primary} and {partner} press their bodies together and kiss deeply, "
|
|
f"with hands on each other's face, waist, and hips."
|
|
)
|
|
return (
|
|
f"{primary} and {partner} are pressed close in a heated foreplay setup, "
|
|
f"hands caressing skin while clothing is pulled aside."
|
|
)
|
|
|
|
def interaction_text() -> str:
|
|
return " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
item_text,
|
|
*((item_axis_values or {}).values()),
|
|
)
|
|
)
|
|
|
|
def manual_position_graph(primary: str, partner: str = "") -> str:
|
|
text = interaction_text()
|
|
if not partner:
|
|
if "mutual" in text:
|
|
return f"{primary} faces the camera with thighs open, both hands on her body for solo mutual-style masturbation framing."
|
|
return f"{primary} reclines with thighs open, one hand between her legs and fingers visibly stimulating her pussy."
|
|
if "mutual" in text:
|
|
return f"{primary} and {partner} sit close facing each other, both touching themselves while keeping hands, faces, and bodies visible."
|
|
if "clit" in text or "clitoris" in text:
|
|
return f"{primary} reclines with thighs open while {partner}'s hand is between her legs, fingers rubbing her clit as her hips tilt toward the touch."
|
|
if "toy" in text or "vibrator" in text:
|
|
return f"{primary} reclines with thighs open while {partner} holds a vibrator or toy against her clit, one hand keeping her thigh open."
|
|
return f"{primary} reclines with thighs open while {partner}'s hand is between her legs, fingers visibly stimulating her pussy."
|
|
|
|
def interaction_position_graph(primary: str, partner: str, third: str = "") -> str:
|
|
text = interaction_text()
|
|
if "aftercare" in slug or any(term in text for term in ("aftercare", "cleanup", "wiping", "towel", "post-sex", "cuddle")):
|
|
if "cleanup" in text or "wiping" in text or "towel" in text:
|
|
return f"{primary} reclines after sex while {partner} kneels close and wipes her skin with a towel, hands and relaxed body contact visible."
|
|
return f"{primary} and {partner} lie close together after sex, bodies relaxed and hands resting on skin in a post-sex cuddle."
|
|
if "camera_performance" in slug or any(term in text for term in ("camera", "presenting", "showing", "viewer", "creator-shot")):
|
|
if third:
|
|
return f"{primary} faces the camera while {partner} and {third} hold and present her body, hands framing the exposed skin for the viewer."
|
|
return f"{primary} faces the camera and presents her body while {partner}'s hands hold her hips or thighs open for a clear creator-shot reveal."
|
|
if "body_worship" in slug or any(term in text for term in ("body worship", "nipple", "thigh", "mouth on skin", "kissing down", "ass grabbing")):
|
|
if "ass" in text:
|
|
return f"{primary} stands or kneels with hips angled back while {partner}'s hands grip her ass, fingers pressing into skin."
|
|
if "thigh" in text:
|
|
return f"{primary} reclines with thighs open while {partner} kneels close and kisses along her inner thighs, hands holding her legs in place."
|
|
if "nipple" in text or "breast" in text:
|
|
return f"{primary} arches toward {partner} while {partner}'s mouth is on her breast and one hand cups or squeezes the other breast."
|
|
return f"{primary} reclines or leans back while {partner} kisses down her body, hands tracing breasts, waist, hips, and thighs."
|
|
if "clothing_position" in slug or any(term in text for term in ("transition", "turning", "pulling onto", "lifting", "guided backward", "clothing", "garment")):
|
|
if "turn" in text or "rear-facing" in text:
|
|
return f"{partner}'s hands turn {primary} around by the hips, clothing partly moved aside as her body rotates into the next pose."
|
|
if "legs" in text or "thigh" in text:
|
|
return f"{primary} lies back while {partner} lifts and spreads her legs into position, hands and clothing movement clearly visible."
|
|
return f"{primary} and {partner} are mid-transition, with {partner}'s hands moving clothing aside and guiding {primary}'s hips toward the next pose."
|
|
if "dominant" in slug or any(term in text for term in ("hair", "wrist", "wrists", "jaw", "chin", "guided", "dominant", "control", "dirty talk", "whisper", "mouth near the ear", "verbal teasing")):
|
|
if "dirty talk" in text or "whisper" in text or "mouth near the ear" in text or "verbal teasing" in text:
|
|
return f"{partner} leans close to {primary}'s ear for dirty talk while holding her waist and keeping their bodies pressed close."
|
|
if "wrist" in text or "wrists" in text:
|
|
return f"{primary} lies back while {partner} pins her wrists above her head, both bodies close and the consensual control gesture clearly visible."
|
|
if "hair" in text:
|
|
return f"{partner} holds {primary}'s hair back while guiding her body closer, face and hair-hold gesture visible."
|
|
if "thigh" in text or "spread" in text:
|
|
return f"{primary} reclines with thighs open while {partner}'s hands spread her legs and hold the position for the camera."
|
|
return f"{partner} guides {primary}'s body with hands on her jaw, waist, and hips, keeping the consensual control gesture readable."
|
|
return foreplay_position_graph(primary, partner)
|
|
|
|
def group_coordination_graph(primary: str, partner: str, third: str) -> str:
|
|
observer = third or any_person({primary, partner})
|
|
text = interaction_text()
|
|
if "camera" in text or "hold" in text or "present" in text:
|
|
return f"{primary} is centered while {partner} and {observer} hold and present the body for the camera, each role clearly visible."
|
|
if "watch" in text or "waiting" in text:
|
|
return f"{primary} is centered while {partner} touches her body and {observer} watches close beside them, hands and faces readable."
|
|
return f"{primary} is centered while {partner} touches her body and {observer} stays close as the watching or guiding partner."
|
|
|
|
def mentions_ass(text: str) -> bool:
|
|
return bool(
|
|
re.search(
|
|
r"\bass\b|ass[- ](?:up|raised|exposed|lifted)|spread cheeks|lower back and ass|cum (?:on|dripping from) ass|pussy, ass|ass and",
|
|
text,
|
|
)
|
|
)
|
|
|
|
def climax_position_graph(woman: str, man: str, third: str = "") -> str:
|
|
if "lying between two partners" in item_text and third:
|
|
return f"{woman} lies between {man} and {third}, with {man} under her hips and {third} positioned above her torso as visible semen lands on her body."
|
|
if "held between front-and-back partners" in item_text and third:
|
|
return f"{woman} is held between {man} behind her and {third} in front of her as visible semen lands across her body."
|
|
if "kneeling between standing partners" in item_text and third:
|
|
return f"{woman} kneels between {man} and {third} while both stand close around her face and torso for visible ejaculation."
|
|
if "side-lying with thighs parted" in item_text:
|
|
return f"{woman} lies on her side with thighs parted while {man} kneels beside her hips and ejaculates semen across her thighs and pussy."
|
|
if "sitting on the edge of the bed" in item_text:
|
|
return f"{woman} sits on the edge of the bed with knees spread while {man} stands close between her legs and ejaculates semen across her body."
|
|
if "lying at the bed edge with thighs open" in item_text:
|
|
return f"{woman} lies at the bed edge with thighs open while {man} kneels between her legs and ejaculates semen across her pussy and thighs."
|
|
if "reclining with thighs open" in item_text or "lying on the back with legs spread" in item_text:
|
|
return f"{woman} lies on her back with thighs open while {man} kneels between her legs and ejaculates semen across her pussy and thighs."
|
|
if "on all fours with hips raised" in item_text:
|
|
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and ejaculates semen across her ass, thighs, and lower back."
|
|
if "face-down ass-up" in item_text:
|
|
return f"{woman} lies face-down with ass raised while {man} is positioned behind her and ejaculates semen across her lower back and ass."
|
|
if "bent over with ass raised" in item_text or "bent over" in item_text:
|
|
return f"{woman} is bent forward with hips raised while {man} is positioned behind her, visible semen across her lower back, ass, and thighs."
|
|
if "kneeling with mouth open" in item_text:
|
|
return f"{woman} kneels in front of {man} at hip height while {man} ejaculates semen onto her face, lips, and chest."
|
|
if "kneeling in front of a standing partner" in item_text:
|
|
return f"{woman} kneels in front of {man} at hip height while {man} stands over her for visible ejaculation."
|
|
if "standing with cum on the body" in item_text:
|
|
return f"{woman} stands braced in front of {man} while he stays close at hip level and ejaculates semen across her body."
|
|
if "squatting on top of a partner" in item_text:
|
|
return f"{woman} squats over {man}'s hips while {man} lies on his back under her and ejaculates semen onto her body."
|
|
if "reverse cowgirl over a partner's hips" in item_text:
|
|
return f"{woman} straddles {man}'s hips facing away while {man} lies on his back under her and ejaculates semen onto her body."
|
|
if any(term in item_text for term in ("straddling a partner", "straddling a partner's hips", "shared climax after penetration", "orgasm during penetration")):
|
|
return f"{woman} straddles {man}'s hips while {man} lies on his back under her, their bodies still aligned from penetration as he ejaculates semen onto her body."
|
|
if "seated in a partner's lap facing them" in item_text:
|
|
return f"{woman} sits in {man}'s lap facing him, legs wrapped around his hips as he ejaculates semen across her body."
|
|
if any(term in item_text for term in ("lower back", "cum dripping from ass", "cum on lower back")) or mentions_ass(item_text):
|
|
return f"{woman} is bent forward with hips raised while {man} is positioned behind her, visible semen across her lower back, ass, and thighs."
|
|
if any(term in item_text for term in ("cum on face", "cum on tongue", "cum on lips", "cum on face and lips", "cum on tongue and chin")):
|
|
if third:
|
|
return f"{woman} kneels in the center while {man} and {third} stand close around her face and torso for visible ejaculation."
|
|
return f"{woman} kneels in front of {man} at hip height while {man} ejaculates semen onto her face, lips, and chest."
|
|
return f"{woman} lies on her back with thighs open while {man} kneels between her legs and ejaculates semen onto her body."
|
|
|
|
def penetration_position_graph(woman: str, man: str) -> str:
|
|
text = " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
item_text,
|
|
*((item_axis_values or {}).values()),
|
|
)
|
|
)
|
|
if "missionary" in text:
|
|
return (
|
|
f"{woman} lies on her back with legs open around {man}'s hips while {man} is above her between her thighs; "
|
|
f"{man}'s hips press close and {man}'s penis thrusts into her pussy."
|
|
)
|
|
if "reverse cowgirl" in text:
|
|
return f"{woman} straddles {man}'s hips facing away while {man} lies under her and {man}'s penis thrusts into her pussy."
|
|
if "cowgirl" in text or "straddling" in text:
|
|
return f"{woman} straddles {man}'s hips facing him while {man} lies under her and {man}'s penis thrusts into her pussy."
|
|
if "doggy" in text or "rear-entry" in text or "bent-over" in text or "bent over" in text:
|
|
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and {man}'s penis thrusts into her pussy."
|
|
if "standing" in text:
|
|
return f"{woman} stands braced with hips angled back while {man} stands behind her and {man}'s penis thrusts into her pussy."
|
|
if "spooning" in text or "side-lying" in text:
|
|
return f"{woman} lies on her side with thighs parted while {man} presses behind her and {man}'s penis thrusts into her pussy."
|
|
if "edge-of-bed" in text or "edge of bed" in text or "bed edge" in text or "edge-supported" in text or "raised edge" in text:
|
|
return (
|
|
f"{woman} lies back at a raised edge with hips at the edge and legs open while {man} kneels between her thighs; "
|
|
f"{man}'s hips press close and {man}'s penis thrusts into her pussy."
|
|
)
|
|
if "kneeling straddle" in text:
|
|
return f"{woman} kneels straddling {man}'s hips while {man} supports her waist and {man}'s penis thrusts into her pussy."
|
|
if "lotus" in text:
|
|
return f"{woman} sits in {man}'s lap facing him with legs around his hips while {man}'s penis thrusts into her pussy."
|
|
return (
|
|
f"{woman} lies on her back with legs spread wide and knees bent outward while {man} kneels between her open thighs facing her; "
|
|
f"{man}'s hips are pressed between her legs and {man}'s penis thrusts into her pussy."
|
|
)
|
|
|
|
def anal_position_graph(woman: str, man: str) -> str:
|
|
text = " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
item_text,
|
|
*((item_axis_values or {}).values()),
|
|
)
|
|
)
|
|
if "bent-over" in text or "bent over" in text:
|
|
return f"{woman} is bent forward with hips raised while {man} stands behind her and thrusts his penis into her ass."
|
|
if "face-down" in text:
|
|
return f"{woman} lies face-down with ass raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
if "doggy" in text or "rear-entry" in text:
|
|
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
if "standing" in text:
|
|
return f"{woman} stands braced with hips angled back while {man} stands behind her and thrusts his penis into her ass."
|
|
if "spooning" in text or "side-lying" in text:
|
|
return f"{woman} lies on her side with thighs parted while {man} presses behind her and thrusts his penis into her ass."
|
|
if "edge-of-bed" in text or "edge of bed" in text or "bed edge" in text:
|
|
return f"{woman} lies near a raised edge with hips exposed while {man} kneels behind her and thrusts his penis into her ass."
|
|
if "kneeling" in text:
|
|
return f"{woman} kneels forward with hips raised while {man} kneels behind her and thrusts his penis into her ass."
|
|
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
|
|
def outercourse_position_graph(woman: str, man: str) -> str:
|
|
position_text = str((item_axis_values or {}).get("position") or "").lower()
|
|
text = " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
item_text,
|
|
*((item_axis_values or {}).values()),
|
|
)
|
|
)
|
|
man_is_pov = man in pov_set
|
|
if any(term in text for term in ("boobjob", "titjob", "breast-sex", "breast sex")):
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} kneels between the POV viewer's open thighs with her torso bent forward over his pelvis and shoulders low, "
|
|
"both hands lifting and pressing her breasts tightly around the POV viewer's penis shaft while the glans sits just below her lips."
|
|
)
|
|
return (
|
|
f"{woman} kneels between {man}'s open thighs with her torso bent forward over his pelvis and shoulders low while {man} sits with legs apart, "
|
|
f"{woman}'s hands lifting and pressing her breasts tightly around {man}'s penis shaft while the glans sits just below her lips."
|
|
)
|
|
if any(term in text for term in ("testicle", "balls-licking", "balls licking", "balls and mouth", "balls held")):
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} kneels very low between the POV viewer's open thighs with her torso bent forward and shoulders between his knees, "
|
|
"head tucked under the penis shaft at the base of the penis, mouth and tongue on the POV viewer's balls while his penis points upward above her face."
|
|
)
|
|
return (
|
|
f"{man} sits with legs apart while {woman} kneels very low between his open thighs with her torso bent forward and shoulders between his knees, "
|
|
f"head tucked under the penis shaft at the base of his penis, mouth and tongue on his balls while {man}'s penis points upward above her face."
|
|
)
|
|
if "penis-licking" in position_text or "penis licking" in text or "tongue along" in text or "tongue licking" in text:
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} bends forward between the POV viewer's open thighs, head low under the POV viewer's penis with her face directly under the penis, "
|
|
"tongue running along the underside from the penis shaft to the glans while one hand steadies the base of the penis."
|
|
)
|
|
return (
|
|
f"{woman} bends forward between {man}'s open thighs, head low under {man}'s penis with her face directly under the penis, "
|
|
f"tongue running along the underside from the penis shaft to the glans while one hand steadies the base of the penis."
|
|
)
|
|
if "handjob" in position_text or "handjob" in text or "hand job" in text or "hand wrapped" in text:
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} kneels between the POV viewer's open thighs with her torso leaning forward and face visible behind the penis shaft, "
|
|
"one hand wrapped around the POV viewer's penis shaft while the other hand steadies the base of the penis as she strokes toward the glans."
|
|
)
|
|
return (
|
|
f"{woman} kneels between {man}'s open thighs with her torso leaning forward and face visible behind the penis shaft, "
|
|
f"one hand wrapped around {man}'s penis shaft while the other hand steadies the base of the penis as she strokes toward the glans."
|
|
)
|
|
if "footjob" in text or "soles" in text or "toes curled" in text or "feet stroking" in text:
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} faces the POV viewer with her hips back, torso visible behind her raised legs, and both knees bent open toward the camera, "
|
|
"both soles wrapped around the POV viewer's penis shaft in the lower foreground."
|
|
)
|
|
return (
|
|
f"{man} reclines with hips forward while {woman} faces him with her hips back and both knees bent open, "
|
|
f"wrapping both soles around {man}'s penis shaft while the contact stays centered."
|
|
)
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} kneels close to the POV viewer's hips and keeps the POV viewer's penis centered in clear non-penetrative contact, "
|
|
"with her mouth, hands, breasts, or feet visibly working around the penis shaft."
|
|
)
|
|
return (
|
|
f"{woman} kneels close to {man}'s hips and keeps {man}'s penis centered in clear non-penetrative contact, "
|
|
"with her mouth, hands, breasts, or feet visibly working around the penis shaft."
|
|
)
|
|
|
|
def oral_position_graph(woman: str, man: str) -> str:
|
|
position_text = str((item_axis_values or {}).get("position") or "").lower()
|
|
text = " ".join(
|
|
str(part or "").lower()
|
|
for part in (
|
|
item_text,
|
|
*((item_axis_values or {}).values()),
|
|
)
|
|
)
|
|
man_is_pov = man in pov_set
|
|
woman_gives = any(
|
|
term in text
|
|
for term in (
|
|
"fellatio",
|
|
"blowjob",
|
|
"deepthroat",
|
|
"penis sucking",
|
|
"penis in mouth",
|
|
"penis in her mouth",
|
|
"mouth stretched around a penis",
|
|
"lips wrapped",
|
|
)
|
|
)
|
|
man_gives = any(
|
|
term in text
|
|
for term in (
|
|
"cunnilingus",
|
|
"pussy licking",
|
|
"tongue on pussy",
|
|
"mouth on pussy",
|
|
"pussy and tongue",
|
|
"face-sitting",
|
|
"tongue contact clearly visible",
|
|
)
|
|
)
|
|
if "mouth on genitals" in text and not woman_gives and not man_gives:
|
|
if any(term in text for term in ("face-sitting", "reclining", "straddled", "spread-leg", "open thighs")):
|
|
man_gives = True
|
|
else:
|
|
woman_gives = True
|
|
|
|
if "sixty-nine" in position_text or ("sixty-nine" in text and not position_text):
|
|
return f"{woman} and {man} lie head-to-hips in a sixty-nine position, with {woman}'s mouth on {man}'s penis and {man}'s mouth on {woman}'s pussy."
|
|
if "face-sitting" in position_text or ("face-sitting" in text and not position_text):
|
|
if man_is_pov:
|
|
return (
|
|
f"{woman} is above the POV camera, straddling the POV viewer's face with thighs on both sides of his head, "
|
|
"pussy directly over the POV viewer's mouth for close first-person underview tongue contact."
|
|
)
|
|
return f"{man} lies on his back while {woman} straddles his face with her thighs around his head and {man}'s mouth pressed to her pussy."
|
|
if "straddled oral" in position_text or ("straddled oral" in text and not position_text):
|
|
if woman_gives and not man_gives:
|
|
return f"{man} straddles forward near {woman}'s face while {woman} kneels below him with her mouth on his penis."
|
|
return f"{woman} straddles above {man}'s face with her thighs framing his head while {man}'s mouth stays pressed to her pussy."
|
|
if "side-lying oral" in position_text or ("side-lying oral" in text and not position_text):
|
|
if woman_gives and not man_gives:
|
|
return f"{man} lies on his side with hips angled toward {woman} while {woman} lies beside his thighs and takes his penis in her mouth."
|
|
return f"{woman} lies on her side with her top thigh lifted while {man} lies beside her hips with his mouth pressed to her pussy."
|
|
if (
|
|
"edge-of-bed oral" in position_text
|
|
or "edge of bed oral" in position_text
|
|
or "edge-supported oral" in position_text
|
|
or (("edge-of-bed oral" in text or "edge of bed oral" in text or "edge-supported oral" in text) and not position_text)
|
|
):
|
|
if woman_gives and not man_gives:
|
|
return f"{man} sits at a raised edge with legs apart while {woman} kneels between his thighs and takes his penis in her mouth."
|
|
return f"{woman} lies at a raised edge with thighs open while {man} kneels between her legs with his mouth on her pussy."
|
|
if "standing oral" in position_text or ("standing oral" in text and not position_text):
|
|
if man_gives and not woman_gives:
|
|
return f"{woman} stands braced with one thigh lifted while {man} kneels between her legs with his mouth on her pussy."
|
|
return f"{man} stands with hips forward while {woman} kneels in front of him at hip height and takes his penis in her mouth."
|
|
if "chair oral" in position_text or ("chair oral" in text and not position_text):
|
|
if man_gives and not woman_gives:
|
|
return f"{woman} sits in a chair with thighs open while {man} kneels between her legs with his mouth pressed to her pussy."
|
|
return f"{man} sits in a chair with legs apart while {woman} kneels between his thighs and takes his penis in her mouth."
|
|
if (
|
|
"reclining cunnilingus" in position_text
|
|
or "spread-leg oral" in position_text
|
|
or (("reclining cunnilingus" in text or "spread-leg oral" in text) and not position_text)
|
|
):
|
|
if woman_gives and not man_gives:
|
|
return f"{man} reclines with legs apart while {woman} kneels between his thighs and takes his penis in her mouth."
|
|
return f"{woman} reclines on her back with thighs spread while {man} kneels between her legs with his mouth on her pussy."
|
|
if "kneeling oral" in position_text or ("kneeling oral" in text and not position_text):
|
|
if man_gives and not woman_gives:
|
|
return f"{woman} kneels with thighs parted and hips angled forward while {man} kneels in front of her with his mouth on her pussy."
|
|
return (
|
|
f"{woman} kneels in front of {man}'s penis while {man} stands over her; "
|
|
f"{woman} takes {man}'s penis in her mouth with saliva dripping on the penis as {man} looks down toward her."
|
|
)
|
|
if man_gives and not woman_gives:
|
|
return f"{woman} lies on her back with thighs open while {man} kneels between her legs with his mouth pressed to her pussy."
|
|
return f"{woman} kneels in front of {man}'s hips and takes his penis in her mouth while {man} keeps his hips aligned with her face."
|
|
|
|
if people_count == 1:
|
|
solo = people[0]
|
|
if women_count == 1:
|
|
if "manual_stimulation" in slug:
|
|
return manual_position_graph(solo)
|
|
if "camera_performance" in slug:
|
|
return f"{solo} faces the camera and presents her body with hands framing the exposed skin in a solo creator-shot pose."
|
|
if "cumshot" in slug or "climax" in slug:
|
|
return f"{solo} is shown in a solo explicit orgasm pose with thighs open, one hand on her body, and visible arousal on skin and sheets."
|
|
return f"{solo} is shown in a solo explicit adult pose with self-touch, open body framing, and direct camera awareness."
|
|
if "cumshot" in slug or "climax" in slug:
|
|
return f"{solo} is shown in a solo visible ejaculation pose with one hand on his penis, body angled toward the camera, and semen visible."
|
|
return f"{solo} is shown in a solo explicit adult pose with direct camera awareness and clear body framing."
|
|
|
|
if women_count > 0 and men_count == 0:
|
|
a, b = _pick_distinct(rng, women, 2)
|
|
c = any_woman({a, b}) if len(women) >= 3 else ""
|
|
used = {a, b}
|
|
if "manual_stimulation" in slug:
|
|
graph = manual_position_graph(a, b)
|
|
elif "group_coordination" in slug and c:
|
|
graph = group_coordination_graph(a, b, c)
|
|
used.add(c)
|
|
elif any(token in slug for token in ("foreplay", "body_worship", "clothing_position", "dominant_guidance", "camera_performance", "aftercare")):
|
|
graph = interaction_position_graph(a, b, c)
|
|
if c and "camera_performance" in slug:
|
|
used.add(c)
|
|
elif "foreplay" in slug:
|
|
graph = foreplay_position_graph(a, b)
|
|
elif "outercourse" in slug:
|
|
graph = f"{a} kneels close to {b}'s body and uses mouth, hands, breasts, or feet for explicit non-penetrative contact."
|
|
elif "oral" in slug:
|
|
graph = f"{a} kneels between {b}'s spread thighs and uses tongue and fingers on her pussy."
|
|
elif "anal" in slug or "double" in slug:
|
|
graph = f"{a} uses a strap-on on {b} while keeping her hips held open."
|
|
elif "threesome" in slug or "group" in slug or "orgy" in slug:
|
|
helper = c or any_woman({a})
|
|
graph = f"{a} uses a strap-on on {b} while {helper} gives oral contact and touches both bodies."
|
|
used.add(helper)
|
|
elif "cumshot" in slug or "climax" in slug:
|
|
graph = f"{a} brings {b} to orgasm with mouth and fingers while wetness is visible on thighs and sheets."
|
|
else:
|
|
graph = f"{a} uses a strap-on on {b} while their bodies stay pressed together."
|
|
return graph + support_sentence(used)
|
|
|
|
if men_count > 0 and women_count == 0:
|
|
a, b = _pick_distinct(rng, men, 2)
|
|
c = any_man({a, b}) if len(men) >= 3 else ""
|
|
used = {a, b}
|
|
if "manual_stimulation" in slug:
|
|
graph = f"{a} and {b} sit or recline close together with hands visibly stimulating bodies in a manual sex setup."
|
|
elif "group_coordination" in slug and c:
|
|
graph = group_coordination_graph(a, b, c)
|
|
used.add(c)
|
|
elif any(token in slug for token in ("foreplay", "body_worship", "clothing_position", "dominant_guidance", "camera_performance", "aftercare")):
|
|
graph = f"{a} and {b} press close together, kissing and caressing skin while clothing is pulled aside."
|
|
elif "foreplay" in slug:
|
|
graph = f"{a} and {b} press close together, kissing and caressing skin while clothing is pulled aside."
|
|
elif "outercourse" in slug:
|
|
graph = f"{a} and {b} keep explicit non-penetrative penis contact visible with hands, mouth, or feet."
|
|
elif "oral" in slug:
|
|
graph = f"{a} kneels and takes {b}'s penis in his mouth while holding his hips."
|
|
elif "anal" in slug or "double" in slug or "penetrative" in slug:
|
|
graph = f"{a} penetrates {b} anally while {b}'s hips are held open."
|
|
elif "threesome" in slug or "group" in slug or "orgy" in slug:
|
|
helper = c or any_man({a})
|
|
graph = f"{a} penetrates {b} anally while {helper} gives oral contact from the front."
|
|
used.add(helper)
|
|
elif "cumshot" in slug or "climax" in slug:
|
|
graph = f"{a} ejaculates semen over {b}'s body while {b} keeps eye contact and one hand on his penis."
|
|
else:
|
|
graph = f"{a} and {b} keep explicit penis and anal contact visible."
|
|
return graph + support_sentence(used)
|
|
|
|
# Mixed cast.
|
|
woman = any_woman()
|
|
man = any_man()
|
|
third = any_person({woman, man}) if people_count >= 3 else ""
|
|
if "manual_stimulation" in slug:
|
|
graph = manual_position_graph(woman, man)
|
|
elif "group_coordination" in slug:
|
|
graph = group_coordination_graph(woman, man, third)
|
|
elif any(token in slug for token in ("foreplay", "body_worship", "clothing_position", "dominant_guidance", "camera_performance", "aftercare")):
|
|
graph = interaction_position_graph(woman, man, third)
|
|
elif "foreplay" in slug:
|
|
graph = foreplay_position_graph(woman, man)
|
|
elif "outercourse" in slug:
|
|
graph = outercourse_position_graph(woman, man)
|
|
elif "oral" in slug:
|
|
graph = oral_position_graph(woman, man)
|
|
elif "anal" in slug or "double" in slug:
|
|
if "double" in item_text or "toy" in item_text:
|
|
if people_count >= 3:
|
|
graph = f"{man} thrusts his penis into {woman} while {third} adds a second penetration point from the front."
|
|
else:
|
|
if "bent-over" in item_text or "bent over" in item_text:
|
|
graph = f"{woman} is bent forward with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
elif "face-down" in item_text:
|
|
graph = f"{woman} lies face-down with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
elif "standing" in item_text:
|
|
graph = f"{woman} stands braced with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
elif "kneeling" in item_text:
|
|
graph = f"{woman} kneels forward with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
else:
|
|
graph = f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
|
elif people_count >= 3:
|
|
graph = f"{man} thrusts his penis into {woman} while {third} gives oral contact from the front."
|
|
else:
|
|
graph = anal_position_graph(woman, man)
|
|
elif "threesome" in slug:
|
|
graph = f"{man} thrusts his penis into {woman} while {third or any_person({woman, man})} uses mouth and hands on the exposed body."
|
|
elif "group" in slug or "orgy" in slug:
|
|
graph = f"{man} thrusts his penis into {woman} while surrounding partners give oral contact and keep hands on hips, breasts, and thighs."
|
|
elif "cumshot" in slug or "climax" in slug:
|
|
graph = climax_position_graph(woman, man, third)
|
|
else:
|
|
graph = penetration_position_graph(woman, man)
|
|
return graph + support_sentence({woman, man, third} if third else {woman, man})
|
|
|
|
|
|
def _subject_context(
|
|
rng: random.Random,
|
|
subject_type: str,
|
|
ethnicity: str,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
women_count: int = 1,
|
|
men_count: int = 1,
|
|
) -> dict[str, str]:
|
|
if subject_type in ("woman", "man", "single_any"):
|
|
return _appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black)
|
|
|
|
if subject_type == "configured_cast":
|
|
return _configured_cast_context(women_count, men_count)
|
|
|
|
if subject_type == "couple":
|
|
primary_subject, subject_phrase, pose, effective_women_count, effective_men_count = _couple_type_from_counts(
|
|
rng,
|
|
women_count,
|
|
men_count,
|
|
)
|
|
return {
|
|
"subject_type": "couple",
|
|
"subject": primary_subject,
|
|
"subject_phrase": subject_phrase,
|
|
"age": g.choose(rng, g.COUPLE_AGES),
|
|
"body": g.choose(rng, ["slim and average", "curvy and broad", "stocky and curvy", "average and athletic"]),
|
|
"skin": "",
|
|
"hair": "",
|
|
"eyes": "",
|
|
"body_phrase": "",
|
|
"fallback_pose": pose,
|
|
"women_count": str(effective_women_count),
|
|
"men_count": str(effective_men_count),
|
|
"person_count": "2",
|
|
}
|
|
|
|
if subject_type == "group":
|
|
eth = "Asian " if ethnicity == "asian" else ""
|
|
return {
|
|
"subject_type": "group",
|
|
"subject": f"mixed {eth}adult group",
|
|
"subject_phrase": f"A mixed {eth}adult group of women and men",
|
|
"age": g.choose(rng, g.GROUP_AGES),
|
|
"body": "diverse",
|
|
"skin": "",
|
|
"hair": "",
|
|
"eyes": "",
|
|
"body_phrase": "diverse adult body types",
|
|
}
|
|
|
|
return {
|
|
"subject_type": subject_type,
|
|
"subject": "layout scene",
|
|
"subject_phrase": "Adult layout scene",
|
|
"age": "adult",
|
|
"body": "varied",
|
|
"skin": "",
|
|
"hair": "",
|
|
"eyes": "",
|
|
"body_phrase": "varied adult figures",
|
|
}
|
|
|
|
|
|
def _scene_pool(
|
|
category: dict[str, Any],
|
|
subcategory: dict[str, Any],
|
|
item: Any,
|
|
subject_type: str,
|
|
location_config: dict[str, Any] | None = None,
|
|
) -> list[Any]:
|
|
location_config = location_config or {}
|
|
location_entries = _list_from(location_config.get("scene_entries"))
|
|
if _location_config_active(location_config) and location_config.get("apply_mode") == "replace":
|
|
return location_entries
|
|
fallback = g.GROUP_SCENES if subject_type in ("group", "configured_cast") else g.SCENES
|
|
scene_entries: list[Any] = []
|
|
scene_pools = load_scene_pool_library()
|
|
item_source = item if isinstance(item, dict) else None
|
|
if item_source is not None and _is_false(item_source.get("inherit_scenes")):
|
|
sources = (item_source,)
|
|
elif _is_false(subcategory.get("inherit_scenes")):
|
|
sources = (subcategory, item_source)
|
|
else:
|
|
sources = (category, subcategory, item_source)
|
|
for source in sources:
|
|
if not isinstance(source, dict):
|
|
continue
|
|
if "scenes" in source:
|
|
_unique_extend(scene_entries, _list_from(source["scenes"]))
|
|
refs = _list_from(source.get("scene_pool")) + _list_from(source.get("scene_pools"))
|
|
for ref in refs:
|
|
ref_name = str(ref).strip()
|
|
if ref_name not in scene_pools:
|
|
raise ValueError(f"Unknown scene pool '{ref_name}'")
|
|
_unique_extend(scene_entries, scene_pools[ref_name])
|
|
if _location_config_active(location_config) and location_config.get("apply_mode") == "add":
|
|
_unique_extend(scene_entries, location_entries)
|
|
return scene_entries or fallback
|
|
|
|
|
|
def _legacy_scene_entries_for_row(row: dict[str, Any]) -> list[Any]:
|
|
subject = str(row.get("primary_subject") or "").lower()
|
|
if "group" in subject or "layout" in subject:
|
|
return list(g.GROUP_SCENES)
|
|
return list(g.SCENES)
|
|
|
|
|
|
def _legacy_scene_text_for_slug(slug: str) -> str:
|
|
for entry in list(g.SCENES) + list(g.GROUP_SCENES):
|
|
entry_slug, entry_text = _pair_from(entry)
|
|
if entry_slug == slug:
|
|
return entry_text
|
|
return ""
|
|
|
|
|
|
def _apply_location_config_to_legacy_row(
|
|
row: dict[str, Any],
|
|
location_config: dict[str, Any],
|
|
seed_config: dict[str, int],
|
|
seed: int,
|
|
row_number: int,
|
|
) -> dict[str, Any]:
|
|
if not _location_config_active(location_config):
|
|
return row
|
|
location_entries = _list_from(location_config.get("scene_entries"))
|
|
if location_config.get("apply_mode") == "add":
|
|
choices = _legacy_scene_entries_for_row(row)
|
|
_unique_extend(choices, location_entries)
|
|
else:
|
|
choices = location_entries
|
|
scene_rng = _axis_rng(seed_config, "scene", seed, row_number)
|
|
scene_slug, scene_text = _choose_pair(scene_rng, choices)
|
|
old_slug = str(row.get("scene") or "")
|
|
old_text = _legacy_scene_text_for_slug(old_slug)
|
|
row["source_scene"] = old_slug
|
|
row["source_scene_text"] = old_text
|
|
row["scene"] = scene_slug
|
|
row["scene_text"] = scene_text
|
|
row["location_config"] = location_config
|
|
if old_text:
|
|
row["prompt"] = str(row.get("prompt") or "").replace(f"Scene: {old_text}.", f"Scene: {scene_text}.")
|
|
row["caption"] = str(row.get("caption") or "").replace(f", {old_text},", f", {scene_text},")
|
|
else:
|
|
row["prompt"] = re.sub(
|
|
r"Scene:\s*.*?\.\s*Pose:",
|
|
f"Scene: {scene_text}. Pose:",
|
|
str(row.get("prompt") or ""),
|
|
count=1,
|
|
)
|
|
return row
|
|
|
|
|
|
def _legacy_composition_entries_for_row(row: dict[str, Any]) -> list[Any]:
|
|
subject = str(row.get("primary_subject") or "").lower()
|
|
if "group" in subject or "layout" in subject:
|
|
return list(g.GROUP_COMPOSITIONS)
|
|
return list(g.COMPOSITIONS)
|
|
|
|
|
|
def _apply_composition_config_to_legacy_row(
|
|
row: dict[str, Any],
|
|
composition_config: dict[str, Any],
|
|
seed_config: dict[str, int],
|
|
seed: int,
|
|
row_number: int,
|
|
) -> dict[str, Any]:
|
|
if not _composition_config_active(composition_config):
|
|
return row
|
|
composition_entries = _list_from(composition_config.get("composition_entries"))
|
|
if composition_config.get("apply_mode") == "add":
|
|
choices = _legacy_composition_entries_for_row(row)
|
|
_unique_extend(choices, composition_entries)
|
|
else:
|
|
choices = composition_entries
|
|
composition_rng = _axis_rng(seed_config, "composition", seed, row_number)
|
|
new_composition = _choose_text(composition_rng, choices)
|
|
old_composition = str(row.get("composition") or "")
|
|
old_prompt_fragment = f"Composition: vertical {old_composition}."
|
|
new_prompt_fragment = f"Composition: {_composition_prompt(new_composition)}."
|
|
row["source_composition"] = old_composition
|
|
row["composition"] = new_composition
|
|
row["composition_prompt"] = _composition_prompt(new_composition)
|
|
row["composition_config"] = composition_config
|
|
if old_composition:
|
|
row["prompt"] = str(row.get("prompt") or "").replace(old_prompt_fragment, new_prompt_fragment)
|
|
row["caption"] = str(row.get("caption") or "").replace(f", {old_composition},", f", {new_composition},")
|
|
else:
|
|
row["prompt"] = re.sub(
|
|
r"Composition:\s*.*?\.\s*Use",
|
|
f"{new_prompt_fragment} Use",
|
|
str(row.get("prompt") or ""),
|
|
count=1,
|
|
)
|
|
return row
|
|
|
|
|
|
def _sources_with_inheritance(
|
|
category: dict[str, Any],
|
|
subcategory: dict[str, Any],
|
|
item: Any,
|
|
inherit_key: str,
|
|
) -> tuple[Any, ...]:
|
|
item_source = item if isinstance(item, dict) else None
|
|
if item_source is not None and _is_false(item_source.get(inherit_key)):
|
|
return (item_source,)
|
|
if _is_false(subcategory.get(inherit_key)):
|
|
return (subcategory, item_source)
|
|
return (category, subcategory, item_source)
|
|
|
|
|
|
def _configured_pool(
|
|
category: dict[str, Any],
|
|
subcategory: dict[str, Any],
|
|
item: Any,
|
|
direct_key: str,
|
|
pool_key: str,
|
|
pool_library: dict[str, list[Any]],
|
|
inherit_key: str,
|
|
) -> list[Any]:
|
|
entries: list[Any] = []
|
|
singular_pool_key = pool_key[:-1] if pool_key.endswith("s") else pool_key
|
|
for source in _sources_with_inheritance(category, subcategory, item, inherit_key):
|
|
if not isinstance(source, dict):
|
|
continue
|
|
if direct_key in source:
|
|
_unique_extend(entries, _list_from(source[direct_key]))
|
|
refs = _list_from(source.get(singular_pool_key)) + _list_from(source.get(pool_key))
|
|
for ref in refs:
|
|
ref_name = str(ref).strip()
|
|
if ref_name not in pool_library:
|
|
raise ValueError(f"Unknown {singular_pool_key} '{ref_name}'")
|
|
_unique_extend(entries, pool_library[ref_name])
|
|
return entries
|
|
|
|
|
|
def _expression_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any) -> list[Any]:
|
|
return _configured_pool(
|
|
category,
|
|
subcategory,
|
|
item,
|
|
"expressions",
|
|
"expression_pools",
|
|
load_expression_pool_library(),
|
|
"inherit_expressions",
|
|
) or g.EXPRESSIONS
|
|
|
|
|
|
def _expression_intensity_hint(entry: Any) -> float:
|
|
if isinstance(entry, dict):
|
|
for key in ("expression_intensity", "intensity"):
|
|
if key in entry:
|
|
return _clamped_float(entry[key], 0.5)
|
|
|
|
text = _entry_text(entry).lower()
|
|
high_terms = (
|
|
"ahegao",
|
|
"orgasm",
|
|
"climax",
|
|
"drool",
|
|
"drooling",
|
|
"tongue out",
|
|
"eyes rolled",
|
|
"fucked-out",
|
|
"cum-smeared",
|
|
"saliva",
|
|
"gagging",
|
|
"slack jaw",
|
|
"jaw slack",
|
|
"slack-jawed",
|
|
"sex-drunk",
|
|
"overwhelmed",
|
|
"strained",
|
|
"messy",
|
|
"panting",
|
|
"trembling",
|
|
"shaking",
|
|
"wide open mouth",
|
|
"raw ",
|
|
"wild ",
|
|
"dazed",
|
|
"spent",
|
|
)
|
|
if any(term in text for term in high_terms):
|
|
return 0.9
|
|
|
|
medium_terms = (
|
|
"seductive",
|
|
"teasing",
|
|
"lustful",
|
|
"aroused",
|
|
"bedroom",
|
|
"dominant",
|
|
"predatory",
|
|
"control",
|
|
"stern",
|
|
"strict",
|
|
"smirk",
|
|
"parted lips",
|
|
"open-mouthed",
|
|
"heated",
|
|
"hungry",
|
|
"inviting",
|
|
"sensual",
|
|
"fetish",
|
|
"commanding",
|
|
"flushed",
|
|
"moan",
|
|
)
|
|
if any(term in text for term in medium_terms):
|
|
return 0.62
|
|
|
|
low_terms = (
|
|
"neutral",
|
|
"quiet",
|
|
"calm",
|
|
"reserved",
|
|
"relaxed",
|
|
"candid",
|
|
"closed-mouth",
|
|
"thoughtful",
|
|
"controlled",
|
|
"focused",
|
|
"steady",
|
|
"bitten-lip",
|
|
"braced",
|
|
"held breath",
|
|
"concentrated",
|
|
"aloof",
|
|
"bored",
|
|
"tired",
|
|
"unfocused",
|
|
"contented",
|
|
"fashion",
|
|
"soft",
|
|
"sleepy",
|
|
"fresh-faced",
|
|
)
|
|
if any(term in text for term in low_terms):
|
|
return 0.25
|
|
return 0.5
|
|
|
|
|
|
def _expression_entries_for_intensity(entries: list[Any], expression_intensity: float) -> list[Any]:
|
|
target = _clamped_float(expression_intensity, 0.5)
|
|
weighted: list[Any] = []
|
|
for entry in entries:
|
|
entry_intensity = _expression_intensity_hint(entry)
|
|
distance = abs(target - entry_intensity)
|
|
if distance <= 0.18:
|
|
intensity_weight = 4.0
|
|
elif distance <= 0.35:
|
|
intensity_weight = 1.4
|
|
elif distance <= 0.55:
|
|
intensity_weight = 0.35
|
|
else:
|
|
intensity_weight = 0.05
|
|
|
|
if isinstance(entry, dict):
|
|
adjusted = dict(entry)
|
|
try:
|
|
base_weight = float(adjusted.get("weight", 1.0))
|
|
except (TypeError, ValueError):
|
|
base_weight = 1.0
|
|
adjusted["weight"] = max(0.0, base_weight) * intensity_weight
|
|
weighted.append(adjusted)
|
|
else:
|
|
weighted.append({"text": _entry_text(entry), "weight": intensity_weight})
|
|
return weighted or entries
|
|
|
|
|
|
def _pose_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any, subject_type: str, poses: str) -> list[Any]:
|
|
configured = _merged_field(category, subcategory, item, "poses")
|
|
if configured:
|
|
return _list_from(configured)
|
|
if subject_type == "couple":
|
|
return [entry[2] for entry in g.COUPLE_TYPES]
|
|
if subject_type in ("layout", "scene"):
|
|
return ["clean designed layout"]
|
|
return g.EVOCATIVE_ALL if poses == "evocative" else g.POSES
|
|
|
|
|
|
def _composition_pool(
|
|
category: dict[str, Any],
|
|
subcategory: dict[str, Any],
|
|
item: Any,
|
|
subject_type: str,
|
|
composition_config: dict[str, Any] | None = None,
|
|
) -> list[Any]:
|
|
composition_config = composition_config or {}
|
|
composition_entries = _list_from(composition_config.get("composition_entries"))
|
|
if _composition_config_active(composition_config) and composition_config.get("apply_mode") == "replace":
|
|
return composition_entries
|
|
configured = _configured_pool(
|
|
category,
|
|
subcategory,
|
|
item,
|
|
"compositions",
|
|
"composition_pools",
|
|
load_composition_pool_library(),
|
|
"inherit_compositions",
|
|
)
|
|
if _composition_config_active(composition_config) and composition_config.get("apply_mode") == "add":
|
|
configured = list(configured or [])
|
|
_unique_extend(configured, composition_entries)
|
|
if configured:
|
|
return configured
|
|
if subject_type in ("group", "configured_cast"):
|
|
return g.GROUP_COMPOSITIONS
|
|
if subject_type in ("layout", "scene"):
|
|
return ["designed illustration layout"]
|
|
return g.COMPOSITIONS
|
|
|
|
|
|
def _build_custom_row(
|
|
category_choice: str,
|
|
subcategory_choice: str,
|
|
row_number: int,
|
|
start_index: int,
|
|
ethnicity: str,
|
|
poses: str,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
women_count: int,
|
|
men_count: int,
|
|
seed: int,
|
|
seed_config: dict[str, int],
|
|
expression_enabled: bool,
|
|
expression_intensity: float,
|
|
expression_intensity_source: str = "input",
|
|
character_profile: str | dict[str, Any] | None = None,
|
|
character_cast: str | dict[str, Any] | list[Any] | None = None,
|
|
expression_phase: str = "",
|
|
hardcore_position_config: str | dict[str, Any] | None = None,
|
|
location_config: str | dict[str, Any] | None = None,
|
|
composition_config: str | dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
categories = load_category_library()
|
|
category_rng = _axis_rng(seed_config, "category", seed, row_number)
|
|
subcategory_rng = _axis_rng(seed_config, "subcategory", seed, row_number)
|
|
person_rng = _axis_rng(seed_config, "person", seed, row_number)
|
|
scene_rng = _axis_rng(seed_config, "scene", seed, row_number)
|
|
pose_rng = _axis_rng(seed_config, "pose", seed, row_number)
|
|
role_rng = _axis_rng(seed_config, "role", seed, row_number)
|
|
expression_rng = _axis_rng(seed_config, "expression", seed, row_number)
|
|
composition_rng = _axis_rng(seed_config, "composition", seed, row_number)
|
|
parsed_hardcore_position_config = _parse_hardcore_position_config(hardcore_position_config)
|
|
parsed_location_config = _parse_location_config(location_config)
|
|
parsed_composition_config = _parse_composition_config(composition_config)
|
|
|
|
requested_women_count = women_count
|
|
requested_men_count = men_count
|
|
categories = _filter_hardcore_categories_for_position(
|
|
categories,
|
|
parsed_hardcore_position_config,
|
|
women_count,
|
|
men_count,
|
|
)
|
|
category, subcategory, women_count, men_count = _find_subcategory(
|
|
categories,
|
|
category_choice,
|
|
subcategory_choice,
|
|
category_rng,
|
|
subcategory_rng,
|
|
women_count,
|
|
men_count,
|
|
)
|
|
count_adjustment = {}
|
|
if women_count != requested_women_count or men_count != requested_men_count:
|
|
count_adjustment = {
|
|
"requested_women_count": requested_women_count,
|
|
"requested_men_count": requested_men_count,
|
|
"effective_women_count": women_count,
|
|
"effective_men_count": men_count,
|
|
}
|
|
if _is_hardcore_sexual_category(category):
|
|
subcategory = _apply_hardcore_position_config_to_subcategory(subcategory, parsed_hardcore_position_config)
|
|
content_axis = "pose" if _is_pose_content_category(category, subcategory) else "content"
|
|
content_rng = _axis_rng(seed_config, content_axis, seed, row_number)
|
|
items = _list_from(subcategory.get("items", [subcategory["name"]]))
|
|
item = _weighted_choice(content_rng, items)
|
|
item_text, item_name, item_axis_values = _compose_item(content_rng, category, subcategory, item, women_count, men_count)
|
|
is_pose_category = _is_pose_content_category(category, subcategory)
|
|
if is_pose_category:
|
|
item_text = _sanitize_hardcore_environment_anchors(item_text)
|
|
item_axis_values = _sanitize_hardcore_axis_values(item_axis_values)
|
|
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)
|
|
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)
|
|
subject_type = context["subject_type"]
|
|
pov_character_labels = (
|
|
_pov_character_labels(character_slot_map, men_count)
|
|
if subject_type == "configured_cast"
|
|
else []
|
|
)
|
|
source_role_graph = _role_graph(role_rng, subcategory, context, item_axis_values, pov_character_labels)
|
|
if is_pose_category:
|
|
source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph)
|
|
role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels)
|
|
cast_descriptors: list[str] = []
|
|
cast_descriptor_text = ""
|
|
expression_intensity_source = expression_intensity_source or "input"
|
|
expression_disabled = not bool(expression_enabled)
|
|
if expression_disabled:
|
|
expression_intensity_source = "disabled"
|
|
elif subject_type in ("woman", "man") and applied_slot:
|
|
slot_label = "Woman A" if subject_type == "woman" else "Man A"
|
|
if not _slot_expression_enabled(applied_slot):
|
|
expression_disabled = True
|
|
expression_intensity_source = f"character_slot:{slot_label}:disabled"
|
|
else:
|
|
slot_expression_intensity = _slot_expression_intensity_for_phase(applied_slot, expression_phase)
|
|
if slot_expression_intensity is not None:
|
|
expression_intensity = slot_expression_intensity
|
|
expression_intensity_source = f"character_slot:{slot_label}"
|
|
elif subject_type == "configured_cast" and character_slots:
|
|
expression_intensity, expression_intensity_source = _cast_expression_intensity_override(
|
|
expression_intensity,
|
|
character_slot_map,
|
|
women_count,
|
|
men_count,
|
|
expression_phase,
|
|
)
|
|
if expression_intensity is None:
|
|
expression_disabled = True
|
|
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, parsed_location_config),
|
|
women_count,
|
|
men_count,
|
|
),
|
|
)
|
|
pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text(
|
|
pose_rng, _compatible_entries(_pose_pool(category, subcategory, item, subject_type, poses), women_count, men_count)
|
|
))
|
|
if is_pose_category:
|
|
pose = _sanitize_hardcore_environment_anchors(pose)
|
|
expression_pool = _expression_pool(category, subcategory, item)
|
|
if expression_disabled:
|
|
expression = ""
|
|
else:
|
|
expression_entries = _compatible_entries(
|
|
_expression_entries_for_intensity(expression_pool, expression_intensity),
|
|
women_count,
|
|
men_count,
|
|
)
|
|
expression = _choose_text(expression_rng, expression_entries)
|
|
if subject_type in ("couple", "group") and ";" not in expression:
|
|
secondary_expression = _choose_distinct_text(expression_rng, expression_entries, expression)
|
|
if secondary_expression:
|
|
expression = f"{expression}; {secondary_expression}"
|
|
shared_expression = expression
|
|
character_expressions: list[str] = []
|
|
character_expression_text = ""
|
|
if not expression_disabled and subject_type == "configured_cast" and character_slots:
|
|
character_expressions = _character_expression_entries(
|
|
expression_rng,
|
|
expression_pool,
|
|
expression_intensity,
|
|
character_slot_map,
|
|
women_count,
|
|
men_count,
|
|
expression_phase,
|
|
)
|
|
character_expression_text = "; ".join(character_expressions)
|
|
character_expression_text = _sanitize_character_expression_text_for_action(
|
|
character_expression_text,
|
|
source_role_graph,
|
|
item,
|
|
item_axis_values,
|
|
)
|
|
character_expressions = [part.strip() for part in character_expression_text.split(";") if part.strip()]
|
|
if character_expression_text:
|
|
expression = character_expression_text
|
|
source_composition = _choose_text(
|
|
composition_rng,
|
|
_compatible_entries(
|
|
_composition_pool(category, subcategory, item, subject_type, parsed_composition_config),
|
|
women_count,
|
|
men_count,
|
|
),
|
|
)
|
|
if is_pose_category:
|
|
source_composition = _sanitize_hardcore_environment_anchors(source_composition)
|
|
composition = _pov_composition_prompt(source_composition, pov_character_labels)
|
|
|
|
negative_prompt = str(_merged_field(category, subcategory, item, "negative_prompt", g.NEGATIVE_PROMPT))
|
|
positive_suffix = str(_merged_field(category, subcategory, item, "positive_suffix", GENERIC_POSITIVE_SUFFIX))
|
|
style = str(
|
|
_merged_field(
|
|
category,
|
|
subcategory,
|
|
item,
|
|
"style",
|
|
"sexy but tasteful adult pin-up coloured-pencil comic illustration",
|
|
)
|
|
)
|
|
item_label = str(_merged_field(category, subcategory, item, "item_label", category["name"]))
|
|
|
|
context.update(
|
|
{
|
|
"trigger": g.TRIGGER,
|
|
"main_category": category["name"],
|
|
"subcategory": subcategory["name"],
|
|
"category": category["name"],
|
|
"item": item_text,
|
|
"item_name": item_name,
|
|
"item_label": item_label,
|
|
"style": style,
|
|
"scene": scene,
|
|
"scene_slug": scene_slug,
|
|
"pose": pose,
|
|
"expression": expression,
|
|
"shared_expression": shared_expression,
|
|
"character_expressions": character_expressions,
|
|
"character_expression_text": character_expression_text,
|
|
"expression_enabled": not expression_disabled,
|
|
"expression_disabled": expression_disabled,
|
|
"expression_intensity": expression_intensity,
|
|
"expression_intensity_source": expression_intensity_source,
|
|
"composition": composition,
|
|
"source_composition": source_composition,
|
|
"composition_prompt": _composition_prompt(composition),
|
|
"composition_config": parsed_composition_config if _composition_config_active(parsed_composition_config) else {},
|
|
"role_graph": role_graph,
|
|
"source_role_graph": source_role_graph,
|
|
"pov_character_labels": pov_character_labels,
|
|
"pov_prompt_directive": _pov_prompt_directive(pov_character_labels),
|
|
"cast_descriptors": cast_descriptor_text,
|
|
"positive_suffix": positive_suffix,
|
|
"negative_prompt": negative_prompt,
|
|
}
|
|
)
|
|
|
|
if isinstance(item, dict) and "prompt_template" in item:
|
|
template = str(item["prompt_template"])
|
|
else:
|
|
template = str(subcategory.get("prompt_template") or category.get("prompt_template") or "")
|
|
if not template:
|
|
if subject_type in ("woman", "man"):
|
|
template = SINGLE_TEMPLATE
|
|
elif subject_type == "couple":
|
|
template = COUPLE_TEMPLATE
|
|
elif subject_type == "group":
|
|
template = GROUP_TEMPLATE
|
|
else:
|
|
template = LAYOUT_TEMPLATE
|
|
|
|
caption_template = str(
|
|
(item.get("caption_template") if isinstance(item, dict) else None)
|
|
or subcategory.get("caption_template")
|
|
or category.get("caption_template")
|
|
or "{trigger}, {subject_phrase}, {age}, {item}, {scene}, {composition}, coloured pencil comic illustration"
|
|
)
|
|
|
|
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}.")
|
|
if subject_type == "configured_cast" and pov_character_labels:
|
|
prompt = _insert_positive_directive(prompt, _pov_prompt_directive(pov_character_labels))
|
|
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)
|
|
index = start_index + row_number - 1
|
|
row = g.row_base(index, batch, context["subject"], context["age"], context["body"], scene_slug, composition)
|
|
row.update(
|
|
{
|
|
"prompt": prompt,
|
|
"caption": caption,
|
|
"negative_prompt": negative_prompt,
|
|
"expression": expression,
|
|
"main_category": category["name"],
|
|
"subcategory": subcategory["name"],
|
|
"category_slug": category["slug"],
|
|
"subcategory_slug": subcategory["slug"],
|
|
"subject_type": subject_type,
|
|
"subject_phrase": context.get("subject_phrase", ""),
|
|
"body_phrase": context.get("body_phrase", ""),
|
|
"skin": context.get("skin", ""),
|
|
"hair": context.get("hair", ""),
|
|
"eyes": context.get("eyes", ""),
|
|
"style": style,
|
|
"item": item_text,
|
|
"item_label": item_label,
|
|
"positive_suffix": positive_suffix,
|
|
"custom_item": item_name,
|
|
"item_axis_values": item_axis_values,
|
|
"scene_text": scene,
|
|
"location_config": parsed_location_config if _location_config_active(parsed_location_config) else {},
|
|
"pose": pose,
|
|
"seed_config": seed_config,
|
|
"hardcore_position_config": (
|
|
parsed_hardcore_position_config
|
|
if _hardcore_position_config_active(parsed_hardcore_position_config)
|
|
else {}
|
|
),
|
|
"content_seed_axis": content_axis,
|
|
"role_graph": role_graph,
|
|
"source_role_graph": source_role_graph,
|
|
"source_composition": source_composition,
|
|
"pov_character_labels": pov_character_labels,
|
|
"pov_prompt_directive": _pov_prompt_directive(pov_character_labels),
|
|
"shared_expression": shared_expression,
|
|
"character_expressions": character_expressions,
|
|
"character_expression_text": character_expression_text,
|
|
"expression_enabled": not expression_disabled,
|
|
"expression_disabled": expression_disabled,
|
|
"cast_summary": context.get("cast_summary", ""),
|
|
"cast_descriptors": cast_descriptors,
|
|
"cast_descriptor_text": cast_descriptor_text,
|
|
"scene_kind": context.get("scene_kind", ""),
|
|
"women_count": context.get("women_count", ""),
|
|
"men_count": context.get("men_count", ""),
|
|
"person_count": context.get("person_count", ""),
|
|
"cast_count_adjustment": count_adjustment if subject_type == "configured_cast" else {},
|
|
"character_profile": applied_profile,
|
|
"character_profile_status": profile_status,
|
|
"character_slot": applied_slot,
|
|
"character_slot_status": slot_status,
|
|
"character_cast_slots": character_slots,
|
|
"expression_intensity": expression_intensity,
|
|
"expression_intensity_source": expression_intensity_source,
|
|
"source": "json_category",
|
|
}
|
|
)
|
|
if context.get("figure"):
|
|
row["figure"] = context["figure"]
|
|
if expression_disabled:
|
|
row = _disable_row_expression(row, expression_intensity_source)
|
|
return row
|
|
|
|
|
|
def build_prompt(
|
|
category: str,
|
|
subcategory: str,
|
|
row_number: int,
|
|
start_index: int,
|
|
seed: int,
|
|
clothing: str,
|
|
ethnicity: str,
|
|
poses: str,
|
|
backside_bias: float,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
minimal_clothing_ratio: float,
|
|
standard_pose_ratio: float,
|
|
trigger: str,
|
|
prepend_trigger_to_prompt: bool,
|
|
extra_positive: str,
|
|
extra_negative: str,
|
|
seed_config: str | dict[str, Any] | None = None,
|
|
women_count: int = 1,
|
|
men_count: int = 1,
|
|
camera_config: str | dict[str, Any] | None = None,
|
|
expression_intensity: float = 0.5,
|
|
character_profile: str | dict[str, Any] | None = None,
|
|
character_cast: str | dict[str, Any] | list[Any] | None = None,
|
|
expression_enabled: bool = True,
|
|
expression_phase: str = "",
|
|
hardcore_position_config: str | dict[str, Any] | None = None,
|
|
location_config: str | dict[str, Any] | None = None,
|
|
composition_config: str | dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
apply_pool_extensions()
|
|
row_number = max(1, int(row_number))
|
|
start_index = max(1, int(start_index))
|
|
seed = int(seed)
|
|
ethnicity = normalize_ethnicity_filter(ethnicity, "any")
|
|
expression_enabled = not _is_false(expression_enabled)
|
|
minimal_ratio = _ratio_or_none(minimal_clothing_ratio)
|
|
pose_ratio = _ratio_or_none(standard_pose_ratio)
|
|
parsed_seed_config = _parse_seed_config(seed_config)
|
|
parsed_location_config = _parse_location_config(location_config)
|
|
parsed_composition_config = _parse_composition_config(composition_config)
|
|
content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number)
|
|
pose_axis_rng = _axis_rng(parsed_seed_config, "pose", seed, row_number)
|
|
person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number)
|
|
expression_rng = _axis_rng(parsed_seed_config, "expression", seed, row_number)
|
|
clothing = clothing if clothing in ("full", "minimal", "random") else "full"
|
|
poses = poses if poses in ("standard", "evocative", "random") else "standard"
|
|
figure = figure if figure in ("curvy", "balanced", "bombshell", "random") else "curvy"
|
|
clothing = _pick_clothing_mode(content_rng, clothing, minimal_ratio)
|
|
poses = _pick_pose_mode(pose_axis_rng, poses, pose_ratio)
|
|
figure = _pick_figure_bias(person_rng, figure)
|
|
minimal_ratio = None
|
|
pose_ratio = None
|
|
expression_intensity, expression_intensity_source = _pick_expression_intensity(expression_rng, expression_intensity)
|
|
|
|
exact_custom_subcategory = bool(subcategory and subcategory != RANDOM_SUBCATEGORY and " / " in subcategory)
|
|
|
|
if category == "auto_full" and not exact_custom_subcategory:
|
|
category = _auto_full_choice(parsed_seed_config, seed, row_number)
|
|
|
|
if category == "auto_weighted" and not exact_custom_subcategory:
|
|
row = _build_auto_weighted_row(
|
|
row_number,
|
|
start_index,
|
|
clothing,
|
|
ethnicity,
|
|
poses,
|
|
float(backside_bias),
|
|
figure,
|
|
bool(no_plus_women),
|
|
bool(no_black),
|
|
minimal_ratio,
|
|
pose_ratio,
|
|
seed,
|
|
)
|
|
elif category in ("woman", "man", "couple", "group_or_layout") and not exact_custom_subcategory:
|
|
row = _build_direct_builtin_row(
|
|
category,
|
|
row_number,
|
|
start_index,
|
|
clothing,
|
|
ethnicity,
|
|
poses,
|
|
float(backside_bias),
|
|
figure,
|
|
bool(no_plus_women),
|
|
bool(no_black),
|
|
minimal_ratio,
|
|
pose_ratio,
|
|
seed,
|
|
)
|
|
else:
|
|
row = _build_custom_row(
|
|
category,
|
|
subcategory,
|
|
row_number,
|
|
start_index,
|
|
ethnicity,
|
|
poses,
|
|
figure,
|
|
bool(no_plus_women),
|
|
bool(no_black),
|
|
int(women_count),
|
|
int(men_count),
|
|
seed,
|
|
parsed_seed_config,
|
|
expression_enabled,
|
|
expression_intensity,
|
|
expression_intensity_source,
|
|
character_profile,
|
|
character_cast,
|
|
expression_phase,
|
|
hardcore_position_config,
|
|
parsed_location_config,
|
|
parsed_composition_config,
|
|
)
|
|
|
|
if row.get("source") == "built_in_generator":
|
|
row = _apply_location_config_to_legacy_row(
|
|
row,
|
|
parsed_location_config,
|
|
parsed_seed_config,
|
|
seed,
|
|
row_number,
|
|
)
|
|
row = _apply_composition_config_to_legacy_row(
|
|
row,
|
|
parsed_composition_config,
|
|
parsed_seed_config,
|
|
seed,
|
|
row_number,
|
|
)
|
|
if not expression_enabled:
|
|
row = _disable_row_expression(row, "disabled")
|
|
if extra_positive.strip():
|
|
row["prompt"] = f"{row['prompt'].rstrip()} {extra_positive.strip()}"
|
|
row = _apply_camera_config(row, camera_config)
|
|
active_trigger = trigger.strip() or g.TRIGGER
|
|
row["prompt"] = _prepend_trigger(row["prompt"], active_trigger, bool(prepend_trigger_to_prompt))
|
|
row["prompt"] = sanitize_prompt_text(row["prompt"], triggers=(active_trigger,))
|
|
row["caption"] = sanitize_caption_text(row.get("caption", ""), triggers=(active_trigger,))
|
|
row["negative_prompt"] = sanitize_negative_text(
|
|
_combined_negative(row.get("negative_prompt", g.NEGATIVE_PROMPT), extra_negative)
|
|
)
|
|
row["trigger"] = active_trigger
|
|
row.setdefault("expression_intensity", expression_intensity)
|
|
row.setdefault("expression_intensity_source", expression_intensity_source)
|
|
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 = "",
|
|
character_cast: str | dict[str, Any] | list[Any] | None = "",
|
|
hardcore_position_config: str | dict[str, Any] | None = "",
|
|
location_config: str | dict[str, Any] | None = "",
|
|
composition_config: 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_enabled=profile["expression_enabled"],
|
|
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 "",
|
|
character_cast=character_cast or "",
|
|
hardcore_position_config=hardcore_position_config or "",
|
|
location_config=location_config or "",
|
|
composition_config=composition_config or "",
|
|
)
|
|
|
|
|
|
INSTA_OF_SOFT_LEVELS = {
|
|
"social_tease": "Instagram-style thirst-trap post, suggestive polished social feed energy",
|
|
"lingerie_tease": "premium OF teaser set, lingerie-focused, sensual and intimate",
|
|
"implied_nude": "implied nude creator set, strategically covered body and intimate teaser framing",
|
|
"explicit_tease": "stronger adult teaser set with bolder nude-adjacent styling and solo-tease framing",
|
|
"explicit_nude": "explicit nude creator set with fully nude solo-tease framing",
|
|
}
|
|
|
|
INSTA_OF_HARDCORE_LEVELS = {
|
|
"explicit": "explicit adult creator content with clear sexual contact and adult-only framing",
|
|
"hardcore": "hardcore adult creator content with anatomically clear sexual contact and intense body language",
|
|
}
|
|
|
|
INSTA_OF_PLATFORM_STYLES = {
|
|
"hybrid": "hybrid Instagram-to-OF creator shoot, polished social-media framing with intimate subscriber-content energy",
|
|
"instagram": "Instagram-inspired creator shoot, polished mirror-selfie and feed-post aesthetics",
|
|
"onlyfans": "OnlyFans-inspired creator shoot, intimate subscriber-view camera and candid premium-content framing",
|
|
}
|
|
|
|
INSTA_OF_HARDCORE_CLOTHING_CONTINUITY = {
|
|
"none": "",
|
|
"same_outfit": "Woman A keeps her teaser outfit on with the body contact readable",
|
|
"partially_removed": "Woman A's teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed",
|
|
"implied_nude": "Woman A's body is partly exposed, with fabric slipping off or covering only part of the body",
|
|
"explicit_nude": "Woman A's body is fully exposed, bare skin unobstructed",
|
|
}
|
|
|
|
INSTA_OF_NEGATIVE = (
|
|
"minors, childlike appearance, teen, underage, schoolgirl, non-consensual, coercion, rape, "
|
|
"violence, injury, blood, gore, incest, bestiality, watermark, logo, readable username, social media UI"
|
|
)
|
|
|
|
INSTA_OF_SOFT_NEGATIVE = (
|
|
INSTA_OF_NEGATIVE
|
|
+ ", explicit intercourse, penetration, oral sex, cumshot, genital contact, group sex, "
|
|
"shirtless partner, bare-chested partner, partner nudity"
|
|
)
|
|
|
|
INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL = {
|
|
"social_tease": "Casual clothes / Smart casual",
|
|
"lingerie_tease": "Provocative erotic clothes / Provocative lingerie",
|
|
"implied_nude": "Provocative erotic clothes / Provocative lingerie",
|
|
"explicit_tease": "Provocative erotic clothes / Sheer exposed",
|
|
"explicit_nude": "Provocative erotic clothes / Nude accessories",
|
|
}
|
|
|
|
INSTA_OF_SOFTCORE_OUTFITS = {
|
|
"social_tease": [
|
|
"cropped fitted tee, low-rise jeans, delicate jewelry, and polished feed-post styling",
|
|
"oversized off-shoulder sweater with fitted shorts and soft lounge socks",
|
|
"ribbed tank top, mini skirt, hoop earrings, and casual creator styling",
|
|
"silky camisole tucked into relaxed trousers with a subtle waist chain",
|
|
"sporty crop top, bike shorts, clean sneakers, and glossy social-feed styling",
|
|
"button-down shirt tied at the waist over a fitted bralette and denim shorts",
|
|
"body-hugging knit dress with bare shoulders and simple heels",
|
|
"relaxed hoodie half-zipped over a crop top with high-cut shorts",
|
|
],
|
|
"lingerie_tease": [
|
|
"black lace lingerie set with opaque cups, high-waisted briefs, garter straps, and sheer robe",
|
|
"satin bralette and matching high-waisted panties under an oversized shirt",
|
|
"lace bodysuit with opaque cups, soft stockings, and delicate garter details",
|
|
"silk slip dress with thin straps, thigh slit, and subtle lace trim",
|
|
"matching balconette bra and brief set under a loosely draped satin robe",
|
|
"velvet lingerie set with covered cups, garter belt, sheer stockings, and small gold accents",
|
|
"mesh robe over a covered lace teddy, styled as a premium creator teaser",
|
|
"structured corset top with opaque panels, matching briefs, and sheer stockings",
|
|
],
|
|
"implied_nude": [
|
|
"oversized white shirt slipping off one shoulder, body mostly covered, bare legs, and soft creator-shot styling",
|
|
"towel wrap held across the chest and hips, implied nude but fully covered",
|
|
"satin sheet wrapped around the body with shoulders and legs visible but intimate areas covered",
|
|
"open robe held closed by hand, implied nude beneath without explicit exposure",
|
|
"bath towel and damp hair after a shower, covered chest and hips, intimate creator styling",
|
|
"soft blanket wrapped around the body, bare shoulders visible, sensual but covered",
|
|
],
|
|
"explicit_tease": [
|
|
"sheer robe over matching lingerie with intimate areas obscured by lace pattern and pose",
|
|
"wet-look bodysuit with opaque panels, high-cut legs, and glossy club-light styling",
|
|
"transparent mesh dress over covered lingerie, posed as an adult creator teaser",
|
|
"lace teddy with strategic opaque embroidery, garter straps, and sheer stockings",
|
|
"bare-shoulder robe opened around covered lingerie, bold solo adult tease",
|
|
"strappy lingerie set with covered cups and high-waisted bottoms, styled as a stronger solo teaser",
|
|
],
|
|
"explicit_nude": [
|
|
"body fully exposed with jewelry accents and direct adult selfie confidence",
|
|
"mirror-selfie body exposure with jewelry accents and bold creator-shot framing",
|
|
"body fully exposed with direct eye contact and soft creator-shot styling",
|
|
"vanity-mirror body exposure with necklace detail and premium creator-shot styling",
|
|
"shower-afterglow body exposure with wet hair, skin highlights, and phone-shot framing",
|
|
"indoor body exposure with one hand holding the phone and direct camera awareness",
|
|
],
|
|
}
|
|
|
|
INSTA_OF_SOFTCORE_POSES = {
|
|
"social_tease": [
|
|
"taking a mirror selfie with one hip angled and relaxed social-feed confidence",
|
|
"leaning against a doorway with one hand holding the phone and a casual teasing smile",
|
|
"sitting casually for a polished outfit-check selfie",
|
|
"standing by the window with shoulders relaxed and body angled toward the phone",
|
|
"posing in a clean feed-post stance with one hand at the waist",
|
|
"stretching one arm above the head in a casual morning selfie pose",
|
|
],
|
|
"lingerie_tease": [
|
|
"taking a mirror lingerie selfie with one hip angled and the outfit clearly visible",
|
|
"kneeling in a covered lingerie teaser pose with hands resting on fabric",
|
|
"leaning with the robe draped around covered lingerie",
|
|
"standing in a three-quarter lingerie outfit-check pose with legs softly crossed",
|
|
"sitting with stockings and garter details visible in a controlled teaser pose",
|
|
"turning slightly over one shoulder to show the lingerie silhouette",
|
|
],
|
|
"implied_nude": [
|
|
"holding the towel or sheet securely in place while posing for an implied nude selfie",
|
|
"sitting with soft fabric wrapped securely around the body and shoulders visible",
|
|
"standing by a mirror with a towel wrapped around the body",
|
|
"reclining under satin fabric with intimate areas fully obscured",
|
|
"holding an open robe closed in a covered implied nude teaser pose",
|
|
"looking into the phone camera while wrapped in a blanket with bare shoulders visible",
|
|
],
|
|
"explicit_tease": [
|
|
"posing in a stronger adult teaser stance with covered lingerie and direct camera awareness",
|
|
"kneeling with a sheer robe arranged around covered lingerie",
|
|
"standing close to the mirror with the outfit framed boldly",
|
|
"leaning forward slightly with hands on the robe and intimate areas obscured",
|
|
"sitting in a bolder covered lingerie pose with direct eye contact",
|
|
"arching subtly in a solo adult tease while the styling keeps explicit anatomy obscured",
|
|
],
|
|
"explicit_nude": [
|
|
"taking a bold mirror selfie with direct eye contact and the body clearly framed",
|
|
"posing with body fully exposed and jewelry accents as styling",
|
|
"standing with body fully exposed in a premium creator-shot pose",
|
|
"reclining with body fully exposed and the phone held close",
|
|
"turning slightly in a mirror pose with the body framed head-to-thigh",
|
|
"kneeling in a controlled adult teaser pose with body fully exposed and direct phone-camera awareness",
|
|
],
|
|
}
|
|
|
|
INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS = [
|
|
"satin slip dress under an oversized shirt",
|
|
"soft cardigan over a camisole with relaxed trousers",
|
|
"fitted crop top with high-waisted jeans",
|
|
"silky robe over a covered bralette and lounge shorts",
|
|
"bodycon mini dress with simple heels",
|
|
"ribbed tank top with joggers and delicate jewelry",
|
|
"oversized tee with fitted shorts and lounge socks",
|
|
"button-down shirt with a fitted skirt",
|
|
]
|
|
|
|
INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS = [
|
|
"fitted black tee with dark jeans",
|
|
"buttoned linen shirt with chinos",
|
|
"hoodie and joggers",
|
|
"open overshirt over a fitted tank with relaxed trousers",
|
|
"gym tee with track pants and a towel over one shoulder",
|
|
"casual knit shirt with tailored trousers",
|
|
"dark crewneck sweater with jeans",
|
|
"short-sleeve button-up shirt with relaxed shorts",
|
|
]
|
|
|
|
|
|
def character_softcore_outfit_values(source: str, custom_outfits: str = "") -> list[str]:
|
|
source = str(source or "no_change").strip()
|
|
if source in INSTA_OF_SOFTCORE_OUTFITS:
|
|
return list(INSTA_OF_SOFTCORE_OUTFITS[source])
|
|
if source == "partner_woman":
|
|
return list(INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS)
|
|
if source == "partner_man":
|
|
return list(INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS)
|
|
if source == "custom":
|
|
return _normalize_characteristic_values(custom_outfits, None, allow_free_text=True)
|
|
return []
|
|
|
|
|
|
def character_hardcore_clothing_values(state: str, custom_clothing: str = "") -> list[str]:
|
|
state = str(state or "no_change").strip()
|
|
if state == "fully_nude":
|
|
return ["fully nude"]
|
|
if state == "partly_exposed":
|
|
return ["partly nude, body exposed"]
|
|
if state == "same_outfit":
|
|
return ["keeps the teaser outfit on with the body contact readable"]
|
|
if state == "partially_removed":
|
|
return ["teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed"]
|
|
if state == "custom":
|
|
return _normalize_characteristic_values(custom_clothing, None, allow_free_text=True)
|
|
return []
|
|
|
|
|
|
def build_insta_of_options_json(
|
|
softcore_cast: str = "solo",
|
|
hardcore_cast: str = "use_counts",
|
|
hardcore_women_count: int = 1,
|
|
hardcore_men_count: int = 1,
|
|
softcore_level: str = "lingerie_tease",
|
|
hardcore_level: str = "hardcore",
|
|
platform_style: str = "hybrid",
|
|
continuity: str = "same_creator_same_room",
|
|
hardcore_clothing_continuity: str = "partially_removed",
|
|
softcore_camera_mode: str = "handheld_selfie",
|
|
hardcore_camera_mode: str = "from_camera_config",
|
|
camera_detail: str = "from_camera_config",
|
|
softcore_expression_intensity: float = 0.45,
|
|
hardcore_expression_intensity: float = 0.85,
|
|
softcore_expression_enabled: bool = True,
|
|
hardcore_expression_enabled: bool = True,
|
|
hardcore_detail_density: str = "balanced",
|
|
) -> str:
|
|
hardcore_detail_density = (
|
|
hardcore_detail_density if hardcore_detail_density in HARDCORE_DETAIL_DENSITY_CHOICES else "balanced"
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"softcore_cast": softcore_cast,
|
|
"hardcore_cast": hardcore_cast,
|
|
"hardcore_women_count": int(hardcore_women_count),
|
|
"hardcore_men_count": int(hardcore_men_count),
|
|
"softcore_level": softcore_level,
|
|
"hardcore_level": hardcore_level,
|
|
"platform_style": platform_style,
|
|
"continuity": continuity,
|
|
"hardcore_clothing_continuity": hardcore_clothing_continuity,
|
|
"softcore_camera_mode": softcore_camera_mode,
|
|
"hardcore_camera_mode": hardcore_camera_mode,
|
|
"camera_detail": camera_detail,
|
|
"softcore_expression_enabled": not _is_false(softcore_expression_enabled),
|
|
"hardcore_expression_enabled": not _is_false(hardcore_expression_enabled),
|
|
"softcore_expression_intensity": _clamped_float(softcore_expression_intensity, 0.45),
|
|
"hardcore_expression_intensity": _clamped_float(hardcore_expression_intensity, 0.85),
|
|
"hardcore_detail_density": hardcore_detail_density,
|
|
},
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
)
|
|
|
|
|
|
def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
defaults = {
|
|
"softcore_cast": "solo",
|
|
"hardcore_cast": "use_counts",
|
|
"hardcore_women_count": 1,
|
|
"hardcore_men_count": 1,
|
|
"softcore_level": "lingerie_tease",
|
|
"hardcore_level": "hardcore",
|
|
"platform_style": "hybrid",
|
|
"continuity": "same_creator_same_room",
|
|
"hardcore_clothing_continuity": "partially_removed",
|
|
"softcore_camera_mode": "handheld_selfie",
|
|
"hardcore_camera_mode": "from_camera_config",
|
|
"camera_detail": "from_camera_config",
|
|
"softcore_expression_enabled": True,
|
|
"hardcore_expression_enabled": True,
|
|
"softcore_expression_intensity": 0.45,
|
|
"hardcore_expression_intensity": 0.85,
|
|
"hardcore_detail_density": "balanced",
|
|
}
|
|
if not options_json:
|
|
return defaults
|
|
if isinstance(options_json, dict):
|
|
raw = options_json
|
|
else:
|
|
try:
|
|
raw = json.loads(str(options_json))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid Insta/OF options JSON: {exc}") from exc
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("Insta/OF options must be a JSON object")
|
|
parsed = {**defaults, **raw}
|
|
parsed["softcore_cast"] = parsed["softcore_cast"] if parsed["softcore_cast"] in ("solo", "same_as_hardcore") else defaults["softcore_cast"]
|
|
parsed["hardcore_cast"] = parsed["hardcore_cast"] if parsed["hardcore_cast"] in ("use_counts", "couple", "threesome", "group") else defaults["hardcore_cast"]
|
|
parsed["softcore_level"] = parsed["softcore_level"] if parsed["softcore_level"] in INSTA_OF_SOFT_LEVELS else defaults["softcore_level"]
|
|
parsed["hardcore_level"] = parsed["hardcore_level"] if parsed["hardcore_level"] in INSTA_OF_HARDCORE_LEVELS else defaults["hardcore_level"]
|
|
parsed["platform_style"] = parsed["platform_style"] if parsed["platform_style"] in INSTA_OF_PLATFORM_STYLES else defaults["platform_style"]
|
|
parsed["continuity"] = parsed["continuity"] if parsed["continuity"] in ("same_creator_same_room", "same_creator_new_scene") else defaults["continuity"]
|
|
parsed["hardcore_clothing_continuity"] = (
|
|
parsed["hardcore_clothing_continuity"]
|
|
if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY
|
|
else defaults["hardcore_clothing_continuity"]
|
|
)
|
|
parsed["softcore_camera_mode"] = (
|
|
parsed["softcore_camera_mode"]
|
|
if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS or parsed["softcore_camera_mode"] == "from_camera_config"
|
|
else defaults["softcore_camera_mode"]
|
|
)
|
|
if (
|
|
parsed["hardcore_camera_mode"] not in CAMERA_MODE_PROMPTS
|
|
and parsed["hardcore_camera_mode"] not in ("from_camera_config", "same_as_softcore")
|
|
):
|
|
parsed["hardcore_camera_mode"] = defaults["hardcore_camera_mode"]
|
|
parsed["camera_detail"] = (
|
|
parsed["camera_detail"]
|
|
if parsed["camera_detail"] in CAMERA_DETAIL_CHOICES or parsed["camera_detail"] == "from_camera_config"
|
|
else defaults["camera_detail"]
|
|
)
|
|
parsed["softcore_expression_enabled"] = not _is_false(parsed.get("softcore_expression_enabled", True))
|
|
parsed["hardcore_expression_enabled"] = not _is_false(parsed.get("hardcore_expression_enabled", True))
|
|
parsed["softcore_expression_intensity"] = _clamped_float(
|
|
parsed.get("softcore_expression_intensity"),
|
|
defaults["softcore_expression_intensity"],
|
|
)
|
|
parsed["hardcore_expression_intensity"] = _clamped_float(
|
|
parsed.get("hardcore_expression_intensity"),
|
|
defaults["hardcore_expression_intensity"],
|
|
)
|
|
parsed["hardcore_detail_density"] = (
|
|
parsed["hardcore_detail_density"]
|
|
if parsed.get("hardcore_detail_density") in HARDCORE_DETAIL_DENSITY_CHOICES
|
|
else defaults["hardcore_detail_density"]
|
|
)
|
|
for key in ("hardcore_women_count", "hardcore_men_count"):
|
|
try:
|
|
parsed[key] = max(0, min(12, int(parsed[key])))
|
|
except (TypeError, ValueError):
|
|
parsed[key] = defaults[key]
|
|
return parsed
|
|
|
|
|
|
def _insta_camera_config_with_detail(camera_config: dict[str, Any], camera_detail: str) -> dict[str, Any]:
|
|
if camera_detail in CAMERA_DETAIL_CHOICES:
|
|
camera_config["camera_detail"] = camera_detail
|
|
return camera_config
|
|
|
|
|
|
def _insta_of_hardcore_counts(options: dict[str, Any]) -> tuple[int, int]:
|
|
policy = str(options.get("hardcore_cast", "use_counts"))
|
|
if policy == "couple":
|
|
women_count, men_count = 1, 1
|
|
elif policy == "threesome":
|
|
women_count, men_count = 2, 1
|
|
elif policy == "group":
|
|
women_count, men_count = 3, 2
|
|
else:
|
|
women_count = int(options.get("hardcore_women_count") or 0)
|
|
men_count = int(options.get("hardcore_men_count") or 0)
|
|
women_count = max(1, min(12, women_count))
|
|
men_count = max(0, min(12, men_count))
|
|
if women_count + men_count < 2:
|
|
men_count = 1
|
|
return women_count, men_count
|
|
|
|
|
|
def _insta_of_descriptor(row: dict[str, Any]) -> str:
|
|
return _descriptor_from_parts(
|
|
"woman",
|
|
row.get("age_band") or row.get("age"),
|
|
row.get("body_phrase"),
|
|
row.get("skin"),
|
|
row.get("hair"),
|
|
row.get("eyes"),
|
|
row.get("descriptor_detail"),
|
|
)
|
|
|
|
|
|
def _insta_of_descriptor_from_context(context: dict[str, Any]) -> str:
|
|
subject = str(context.get("subject") or context.get("subject_type") or "person").strip()
|
|
return _descriptor_from_parts(
|
|
subject,
|
|
context.get("age"),
|
|
context.get("body_phrase"),
|
|
context.get("skin"),
|
|
context.get("hair"),
|
|
context.get("eyes"),
|
|
context.get("descriptor_detail"),
|
|
)
|
|
|
|
|
|
def _insta_of_cast_descriptors(
|
|
primary_descriptor: str,
|
|
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 = "",
|
|
) -> list[str]:
|
|
descriptors, _slots = _cast_descriptor_entries(
|
|
seed_config,
|
|
seed,
|
|
row_number,
|
|
ethnicity,
|
|
figure,
|
|
no_plus_women,
|
|
no_black,
|
|
women_count,
|
|
men_count,
|
|
character_cast,
|
|
primary_descriptor=primary_descriptor,
|
|
)
|
|
return descriptors
|
|
|
|
|
|
def _insta_of_cast_phrase(women_count: int, men_count: int) -> str:
|
|
context = _configured_cast_context(women_count, men_count)
|
|
return context["cast_summary"]
|
|
|
|
|
|
def _insta_of_prompt_cast_descriptors(text: str) -> str:
|
|
return str(text or "").replace("Woman A / primary creator:", "Woman A:")
|
|
|
|
|
|
SOFTCORE_CAST_POSES = [
|
|
"standing together for a mirror selfie with relaxed close body language",
|
|
"posing shoulder-to-shoulder in a creator-shot group teaser",
|
|
"leaning together in a polished subscriber preview",
|
|
"sitting close together with relaxed hands and styled outfit visibility",
|
|
"arranged around Woman A in a flirtatious creator-teaser pose",
|
|
"posing together as a coordinated adult creator set",
|
|
"standing near the phone tripod with relaxed teasing body language",
|
|
"framed together in a softcore cast reveal",
|
|
]
|
|
|
|
|
|
def _insta_of_softcore_category(level: str) -> tuple[str, str]:
|
|
subcategory = INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL.get(
|
|
level,
|
|
INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL["lingerie_tease"],
|
|
)
|
|
category, _subcategory = subcategory.split(" / ", 1)
|
|
return category, subcategory
|
|
|
|
|
|
def _insta_of_softcore_outfit(rng: random.Random, level: str) -> str:
|
|
pool = INSTA_OF_SOFTCORE_OUTFITS.get(level, INSTA_OF_SOFTCORE_OUTFITS["lingerie_tease"])
|
|
return g.choose(rng, pool)
|
|
|
|
|
|
def _insta_of_softcore_item_prompt_label(level: str) -> str:
|
|
return "Body exposure" if level == "explicit_nude" else "Outfit"
|
|
|
|
|
|
def _insta_of_softcore_pose(rng: random.Random, level: str) -> str:
|
|
pool = INSTA_OF_SOFTCORE_POSES.get(level, INSTA_OF_SOFTCORE_POSES["lingerie_tease"])
|
|
return g.choose(rng, pool)
|
|
|
|
|
|
WOMAN_LOWER_ACCESS_TERMS = (
|
|
"penetrat",
|
|
"thrust",
|
|
"vaginal",
|
|
"anal",
|
|
"rear-entry",
|
|
"rear entry",
|
|
"front-and-back",
|
|
"front and back",
|
|
"double",
|
|
"doggy",
|
|
"missionary",
|
|
"cowgirl",
|
|
"straddles",
|
|
"hips aligned",
|
|
"penis into",
|
|
"penis inside",
|
|
"penis entering",
|
|
"mouth on her pussy",
|
|
"mouth pressed to her pussy",
|
|
"pussy licking",
|
|
"cunnilingus",
|
|
"thighs spread",
|
|
"thighs open",
|
|
"legs spread",
|
|
"legs open",
|
|
"cum on pussy",
|
|
"cum across her pussy",
|
|
"cum dripping from pussy",
|
|
"cum dripping from ass",
|
|
"cum on belly",
|
|
"cum on thighs",
|
|
"cum across her ass",
|
|
"cum across her lower back",
|
|
"toy aligned",
|
|
"second penetration point",
|
|
)
|
|
|
|
WOMAN_UPPER_ACCESS_TERMS = (
|
|
"boobjob",
|
|
"titjob",
|
|
"breast sex",
|
|
"breasts around",
|
|
"breasts tightly",
|
|
"hands pressing both breasts",
|
|
"breasts together",
|
|
"cum on breasts",
|
|
"cum across her breasts",
|
|
"cum on chest",
|
|
)
|
|
|
|
MAN_LOWER_ACCESS_TERMS = (
|
|
"penis",
|
|
"glans",
|
|
"testicle",
|
|
"balls",
|
|
"cumshot",
|
|
"ejaculat",
|
|
"semen",
|
|
"boobjob",
|
|
"titjob",
|
|
"breast sex",
|
|
"footjob",
|
|
"handjob",
|
|
"hand job",
|
|
"hand wrapped",
|
|
"hand stroking",
|
|
"blowjob",
|
|
"fellatio",
|
|
"penis sucking",
|
|
"penis in mouth",
|
|
"mouth on penis",
|
|
"penis licking",
|
|
)
|
|
|
|
LOWER_BODY_CLOTHING_TERMS = (
|
|
"panty",
|
|
"panties",
|
|
"brief",
|
|
"briefs",
|
|
"thong",
|
|
"bottom",
|
|
"bottoms",
|
|
"bodysuit",
|
|
"teddy",
|
|
"dress",
|
|
"skirt",
|
|
"shorts",
|
|
"jeans",
|
|
"trousers",
|
|
"pants",
|
|
"bikini",
|
|
"towel",
|
|
"sheet",
|
|
"blanket",
|
|
)
|
|
|
|
UPPER_BODY_CLOTHING_TERMS = (
|
|
"bra",
|
|
"cup",
|
|
"cups",
|
|
"corset",
|
|
"bodysuit",
|
|
"bustier",
|
|
"top",
|
|
"camisole",
|
|
"shirt",
|
|
"blouse",
|
|
"bodice",
|
|
"dress",
|
|
"robe",
|
|
"jacket",
|
|
"sweater",
|
|
"harness",
|
|
"chest",
|
|
"cleavage",
|
|
"panel",
|
|
"panels",
|
|
)
|
|
|
|
INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS = [
|
|
"wears an open button shirt with jeans lowered below the hips for genital access",
|
|
"wears a fitted tee pushed up with trousers lowered below the hips",
|
|
"keeps a dark shirt on while pants and underwear are pulled down below the hips",
|
|
"wears an open overshirt with jeans pushed down at the thighs",
|
|
"wears a hoodie lifted at the waist with sweatpants lowered below the hips",
|
|
"wears gym shorts pulled down below the hips with his shirt still on",
|
|
"keeps a casual shirt on with belt open and pants lowered below the hips",
|
|
"wears a half-open shirt with lower garments pushed down below the hips",
|
|
]
|
|
|
|
INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE = [
|
|
"wears an open button shirt with jeans unfastened",
|
|
"wears a fitted tee with pants opened at the waist",
|
|
"keeps a dark shirt on with trousers loosened",
|
|
"wears an open overshirt with jeans partly lowered",
|
|
"wears gym shorts loose at the waist with a towel nearby",
|
|
"wears a hoodie lifted at the waist with sweatpants loosened",
|
|
"wears a casual shirt with belt open and pants partly lowered",
|
|
"wears a half-open shirt with dark trousers",
|
|
]
|
|
|
|
|
|
def _hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]:
|
|
axis_values = row.get("item_axis_values")
|
|
axis_text = " ".join(str(value) for value in axis_values.values()) if isinstance(axis_values, dict) else ""
|
|
role_text = " ".join(
|
|
str(part or "")
|
|
for part in (
|
|
row.get("source_role_graph"),
|
|
row.get("role_graph"),
|
|
)
|
|
).lower()
|
|
detail_text = " ".join(
|
|
str(part or "")
|
|
for part in (
|
|
row.get("item"),
|
|
row.get("source_composition"),
|
|
row.get("composition"),
|
|
axis_text,
|
|
)
|
|
).lower()
|
|
full_text = f"{role_text} {detail_text}"
|
|
return {
|
|
"woman_lower": any(term in role_text for term in WOMAN_LOWER_ACCESS_TERMS),
|
|
"woman_upper": any(term in full_text for term in WOMAN_UPPER_ACCESS_TERMS),
|
|
"man_lower": any(term in role_text for term in MAN_LOWER_ACCESS_TERMS),
|
|
}
|
|
|
|
|
|
def _outfit_without_lower_body_blockers(outfit: str) -> str:
|
|
text = str(outfit or "").strip()
|
|
if not text:
|
|
return ""
|
|
text = re.sub(r"\blingerie set\b", "lingerie top details", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\bbrief set\b", "bra set", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\bbodysuit with\b", "upper bodysuit detail with", text, flags=re.IGNORECASE)
|
|
fragments = re.split(r"\s*,\s*|\s+\band\b\s+|\s+\bwith\b\s+|\s+\bunder\b\s+|\s+\bover\b\s+", text)
|
|
kept = []
|
|
for fragment in fragments:
|
|
fragment = fragment.strip(" ,.;")
|
|
fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE)
|
|
if not fragment:
|
|
continue
|
|
lower = fragment.lower()
|
|
if any(term in lower for term in LOWER_BODY_CLOTHING_TERMS):
|
|
continue
|
|
kept.append(fragment)
|
|
if not kept:
|
|
return ""
|
|
deduped = []
|
|
seen = set()
|
|
for fragment in kept:
|
|
key = re.sub(r"\W+", " ", fragment.lower()).strip()
|
|
if key and key not in seen:
|
|
deduped.append(fragment)
|
|
seen.add(key)
|
|
return ", ".join(deduped)
|
|
|
|
|
|
def _outfit_without_upper_body_blockers(outfit: str) -> str:
|
|
text = str(outfit or "").strip()
|
|
if not text:
|
|
return ""
|
|
text = re.sub(r"\blingerie set\b", "lingerie styling", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\bbalconette bra and brief set\b", "briefs and garter styling", text, flags=re.IGNORECASE)
|
|
fragments = re.split(r"\s*,\s*|\s+\band\s+|\s+\bwith\s+|\s+\bunder\s+|\s+\bover\s+", text)
|
|
kept = []
|
|
for fragment in fragments:
|
|
fragment = fragment.strip(" ,.;")
|
|
fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE)
|
|
if not fragment:
|
|
continue
|
|
lower = fragment.lower()
|
|
if any(term in lower for term in UPPER_BODY_CLOTHING_TERMS):
|
|
continue
|
|
kept.append(fragment)
|
|
if not kept:
|
|
return ""
|
|
deduped = []
|
|
seen = set()
|
|
for fragment in kept:
|
|
key = re.sub(r"\W+", " ", fragment.lower()).strip()
|
|
if key and key not in seen:
|
|
deduped.append(fragment)
|
|
seen.add(key)
|
|
return ", ".join(deduped)
|
|
|
|
|
|
def _insta_of_hardcore_clothing_state(mode: str, softcore_outfit: str, woman_access: str = "") -> str:
|
|
mode = mode if mode in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY else "none"
|
|
outfit = str(softcore_outfit or "").strip()
|
|
if mode == "none" or not outfit:
|
|
return ""
|
|
base = INSTA_OF_HARDCORE_CLOTHING_CONTINUITY[mode]
|
|
if mode == "explicit_nude":
|
|
return f"Body exposure: {base}."
|
|
if mode == "implied_nude":
|
|
return f"Body exposure: {base}."
|
|
if mode == "partially_removed" and woman_access == "lower":
|
|
detail = _outfit_without_lower_body_blockers(outfit)
|
|
base = (
|
|
"Woman A's lower body is clear; any lower garment is pulled aside or removed below the hips"
|
|
)
|
|
if detail:
|
|
return f"Clothing state: {base}; visible remaining styling: {detail}."
|
|
return f"Clothing state: {base}."
|
|
if mode == "partially_removed" and woman_access == "upper":
|
|
detail = _outfit_without_upper_body_blockers(outfit)
|
|
base = (
|
|
"Woman A's breasts and upper body are clear; any bra cup, bodice, or top panel is pulled aside or removed"
|
|
)
|
|
if detail:
|
|
return f"Clothing state: {base}; visible remaining styling: {detail}."
|
|
return f"Clothing state: {base}."
|
|
if mode == "partially_removed":
|
|
return f"Clothing state: Woman A keeps the outfit mostly on; teaser outfit detail: {outfit}."
|
|
return f"Clothing state: {base}; teaser outfit detail: {outfit}."
|
|
|
|
|
|
def _default_man_hardcore_clothing_entries(
|
|
men_count: int,
|
|
pov_labels: list[str] | None,
|
|
configured_entries: list[str],
|
|
rng: random.Random,
|
|
needs_lower_access: bool,
|
|
) -> list[str]:
|
|
pov_set = set(pov_labels or [])
|
|
configured_labels = {
|
|
match.group(1)
|
|
for entry in configured_entries
|
|
for match in [re.match(r"^\s*(Man [A-Z])\b", str(entry or ""))]
|
|
if match
|
|
}
|
|
pool = INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS if needs_lower_access else INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE
|
|
entries = []
|
|
for index in range(max(0, int(men_count))):
|
|
label = f"Man {chr(ord('A') + index)}"
|
|
if label in pov_set or label in configured_labels:
|
|
continue
|
|
entries.append(_hardcore_clothing_sentence(label, g.choose(rng, pool)))
|
|
return entries
|
|
|
|
|
|
def _insta_of_partner_styling(
|
|
seed_config: dict[str, int],
|
|
seed: int,
|
|
row_number: int,
|
|
women_count: int,
|
|
men_count: int,
|
|
pov_labels: list[str] | None = None,
|
|
label_map: dict[str, dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
content_rng = _axis_rng(seed_config, "content", seed, row_number + 421)
|
|
pose_rng = _axis_rng(seed_config, "pose", seed, row_number + 421)
|
|
pov_set = set(pov_labels or [])
|
|
outfits: list[str] = []
|
|
for index in range(max(0, women_count - 1)):
|
|
label = chr(ord("B") + index)
|
|
full_label = f"Woman {label}"
|
|
outfit = _slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS)
|
|
sentence = _softcore_outfit_sentence(full_label, outfit)
|
|
if sentence:
|
|
outfits.append(sentence)
|
|
for index in range(max(0, men_count)):
|
|
label = chr(ord("A") + index)
|
|
full_label = f"Man {label}"
|
|
if full_label in pov_set:
|
|
continue
|
|
outfit = _slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or g.choose(content_rng, INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS)
|
|
sentence = _softcore_outfit_sentence(full_label, outfit)
|
|
if sentence:
|
|
outfits.append(sentence)
|
|
return {
|
|
"outfits": outfits,
|
|
"pose": g.choose(pose_rng, SOFTCORE_CAST_POSES),
|
|
}
|
|
|
|
|
|
def _insta_of_active_trigger(prompt: str, trigger: str, enabled: bool) -> str:
|
|
return _prepend_trigger(prompt, trigger, enabled)
|
|
|
|
|
|
def build_insta_of_pair(
|
|
row_number: int,
|
|
start_index: int,
|
|
seed: int,
|
|
ethnicity: str,
|
|
figure: str,
|
|
no_plus_women: bool,
|
|
no_black: bool,
|
|
trigger: str,
|
|
prepend_trigger_to_prompt: bool,
|
|
seed_config: str | dict[str, Any] | None = None,
|
|
options_json: str | dict[str, Any] | None = None,
|
|
filter_config: str | dict[str, Any] | None = None,
|
|
camera_config: str | dict[str, Any] | None = None,
|
|
softcore_camera_config: str | dict[str, Any] | None = None,
|
|
hardcore_camera_config: str | dict[str, Any] | None = None,
|
|
character_profile: str | dict[str, Any] | None = "",
|
|
character_cast: str | dict[str, Any] | list[Any] | None = "",
|
|
hardcore_position_config: str | dict[str, Any] | None = "",
|
|
location_config: str | dict[str, Any] | None = "",
|
|
composition_config: str | dict[str, Any] | None = "",
|
|
extra_positive: str = "",
|
|
extra_negative: str = "",
|
|
) -> dict[str, Any]:
|
|
options = _parse_insta_of_options(options_json)
|
|
if filter_config:
|
|
filters = _parse_filter_config(filter_config)
|
|
ethnicity = filters["ethnicity"]
|
|
figure = filters["figure"]
|
|
no_plus_women = filters["no_plus_women"]
|
|
no_black = filters["no_black"]
|
|
hard_women_count, hard_men_count = _insta_of_hardcore_counts(options)
|
|
active_trigger = trigger.strip() or g.TRIGGER
|
|
parsed_seed_config = _parse_seed_config(seed_config)
|
|
character_slots = _parse_character_cast(character_cast)
|
|
character_slot_map = _character_slot_label_map(character_slots)
|
|
pov_character_labels = _pov_character_labels(character_slot_map, hard_men_count)
|
|
softcore_level_key = str(options["softcore_level"])
|
|
soft_category, soft_subcategory = _insta_of_softcore_category(softcore_level_key)
|
|
soft_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 311)
|
|
hard_content_rng = _axis_rng(parsed_seed_config, "content", seed, row_number + 317)
|
|
soft_person_rng = _axis_rng(parsed_seed_config, "person", seed, row_number)
|
|
soft_expression_women_count = hard_women_count if options["softcore_cast"] == "same_as_hardcore" else 1
|
|
soft_expression_men_count = hard_men_count if options["softcore_cast"] == "same_as_hardcore" else 0
|
|
soft_expression_enabled = bool(options["softcore_expression_enabled"])
|
|
soft_expression_intensity = options["softcore_expression_intensity"]
|
|
soft_expression_intensity_source = "input"
|
|
if soft_expression_enabled:
|
|
soft_expression_intensity, soft_expression_intensity_source = _cast_expression_intensity_override(
|
|
options["softcore_expression_intensity"],
|
|
character_slot_map,
|
|
soft_expression_women_count,
|
|
soft_expression_men_count,
|
|
"softcore",
|
|
)
|
|
if soft_expression_intensity is None:
|
|
soft_expression_enabled = False
|
|
else:
|
|
soft_expression_intensity_source = "disabled"
|
|
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(
|
|
category=soft_category,
|
|
subcategory=soft_subcategory,
|
|
row_number=row_number,
|
|
start_index=start_index,
|
|
seed=seed,
|
|
clothing="minimal",
|
|
ethnicity=ethnicity,
|
|
poses="evocative",
|
|
backside_bias=0.0,
|
|
figure=figure,
|
|
no_plus_women=no_plus_women,
|
|
no_black=no_black,
|
|
minimal_clothing_ratio=-1,
|
|
standard_pose_ratio=-1,
|
|
trigger=active_trigger,
|
|
prepend_trigger_to_prompt=False,
|
|
extra_positive="",
|
|
extra_negative="",
|
|
seed_config=parsed_seed_config,
|
|
women_count=1,
|
|
men_count=0,
|
|
expression_enabled=soft_expression_enabled,
|
|
expression_intensity=soft_expression_intensity,
|
|
character_profile="" if primary_slot else character_profile or "",
|
|
character_cast="",
|
|
location_config=location_config or "",
|
|
composition_config=composition_config or "",
|
|
)
|
|
soft_row["expression_intensity_source"] = soft_expression_intensity_source
|
|
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"
|
|
if not soft_expression_enabled:
|
|
soft_row = _disable_row_expression(soft_row, soft_expression_intensity_source)
|
|
primary_softcore_outfit = _slot_softcore_outfit(primary_slot, soft_content_rng)
|
|
soft_row["item"] = primary_softcore_outfit or _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["item_label"] = "Insta/OF softcore body exposure" if softcore_level_key == "explicit_nude" else "Insta/OF softcore outfit"
|
|
soft_row["softcore_item_prompt_label"] = _insta_of_softcore_item_prompt_label(softcore_level_key)
|
|
soft_row["custom_item"] = "insta_of_softcore_outfit"
|
|
soft_row["softcore_outfit_policy"] = "character_slot:Woman A" if primary_softcore_outfit else "insta_of_safe_softcore"
|
|
if softcore_level_key == "explicit_nude":
|
|
soft_row["source_scene_text"] = soft_row.get("source_scene_text") or soft_row.get("scene_text", "")
|
|
soft_row["scene_text"] = _body_exposure_scene_text(soft_row.get("scene_text", ""))
|
|
soft_row["pov_character_labels"] = (
|
|
pov_character_labels
|
|
if options["softcore_cast"] == "same_as_hardcore"
|
|
else []
|
|
)
|
|
soft_row["pov_prompt_directive"] = _pov_prompt_directive(soft_row["pov_character_labels"])
|
|
if soft_row["pov_character_labels"]:
|
|
soft_row["source_composition"] = soft_row.get("source_composition") or soft_row.get("composition", "")
|
|
soft_row["composition"] = _pov_composition_prompt(
|
|
soft_row["source_composition"],
|
|
soft_row["pov_character_labels"],
|
|
)
|
|
hard_row = build_prompt(
|
|
category="Hardcore sexual poses",
|
|
subcategory=RANDOM_SUBCATEGORY,
|
|
row_number=row_number,
|
|
start_index=start_index,
|
|
seed=seed,
|
|
clothing="minimal",
|
|
ethnicity=ethnicity,
|
|
poses="evocative",
|
|
backside_bias=0.0,
|
|
figure=figure,
|
|
no_plus_women=no_plus_women,
|
|
no_black=no_black,
|
|
minimal_clothing_ratio=-1,
|
|
standard_pose_ratio=-1,
|
|
trigger=active_trigger,
|
|
prepend_trigger_to_prompt=False,
|
|
extra_positive="",
|
|
extra_negative="",
|
|
seed_config=parsed_seed_config,
|
|
women_count=hard_women_count,
|
|
men_count=hard_men_count,
|
|
expression_enabled=options["hardcore_expression_enabled"],
|
|
expression_intensity=options["hardcore_expression_intensity"],
|
|
character_cast=character_cast or "",
|
|
expression_phase="hardcore",
|
|
hardcore_position_config=hardcore_position_config or "",
|
|
location_config=location_config or "",
|
|
composition_config=composition_config or "",
|
|
)
|
|
hard_row["hardcore_detail_density"] = options["hardcore_detail_density"]
|
|
hard_row["pov_character_labels"] = pov_character_labels
|
|
hard_row["pov_prompt_directive"] = _pov_prompt_directive(pov_character_labels)
|
|
|
|
descriptor = _insta_of_descriptor(soft_row)
|
|
cast_descriptors = _insta_of_cast_descriptors(
|
|
descriptor,
|
|
parsed_seed_config,
|
|
seed,
|
|
row_number,
|
|
ethnicity,
|
|
figure,
|
|
no_plus_women,
|
|
no_black,
|
|
hard_women_count,
|
|
hard_men_count,
|
|
character_slots,
|
|
)
|
|
cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors))
|
|
soft_cast_descriptor_text = (
|
|
cast_descriptor_text
|
|
if options["softcore_cast"] == "same_as_hardcore"
|
|
else f"Woman A: {descriptor}"
|
|
)
|
|
soft_partner_styling = _insta_of_partner_styling(
|
|
parsed_seed_config,
|
|
seed,
|
|
row_number,
|
|
hard_women_count if options["softcore_cast"] == "same_as_hardcore" else 1,
|
|
hard_men_count if options["softcore_cast"] == "same_as_hardcore" else 0,
|
|
pov_character_labels if options["softcore_cast"] == "same_as_hardcore" else [],
|
|
character_slot_map,
|
|
)
|
|
if options["softcore_cast"] != "same_as_hardcore":
|
|
soft_partner_styling = {"outfits": [], "pose": ""}
|
|
soft_partner_outfit_text = "; ".join(soft_partner_styling["outfits"])
|
|
platform_style = INSTA_OF_PLATFORM_STYLES[options["platform_style"]]
|
|
soft_level = INSTA_OF_SOFT_LEVELS[options["softcore_level"]]
|
|
hard_level = INSTA_OF_HARDCORE_LEVELS[options["hardcore_level"]]
|
|
hard_camera_mode = options["hardcore_camera_mode"]
|
|
soft_camera_source = softcore_camera_config or camera_config
|
|
hard_camera_source = hardcore_camera_config or camera_config
|
|
if hard_camera_mode == "same_as_softcore":
|
|
hard_camera_mode = options["softcore_camera_mode"]
|
|
hard_camera_source = soft_camera_source
|
|
soft_camera_config = _camera_config_with_mode(soft_camera_source, options["softcore_camera_mode"])
|
|
hard_camera_config = _camera_config_with_mode(hard_camera_source, hard_camera_mode)
|
|
soft_camera_config = _insta_camera_config_with_detail(soft_camera_config, options["camera_detail"])
|
|
hard_camera_config = _insta_camera_config_with_detail(hard_camera_config, options["camera_detail"])
|
|
soft_camera_directive, soft_camera_config = _camera_directive(soft_camera_config)
|
|
hard_camera_directive, hard_camera_config = _camera_directive(hard_camera_config)
|
|
soft_subject_kind = "woman" if options["softcore_cast"] == "solo" else "subjects"
|
|
hard_subject_kind = "couple" if hard_women_count + hard_men_count == 2 else "subjects"
|
|
soft_row = _apply_coworking_composition(soft_row, soft_subject_kind)
|
|
hard_row = _apply_coworking_composition(hard_row, hard_subject_kind)
|
|
hard_scene = soft_row["scene_text"] if options["continuity"] == "same_creator_same_room" else hard_row["scene_text"]
|
|
if hard_scene != hard_row.get("scene_text"):
|
|
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
|
|
hard_row["scene_text"] = hard_scene
|
|
hard_composition = _coworking_composition_prompt(hard_scene, hard_row["composition"], hard_subject_kind)
|
|
if hard_composition != hard_row["composition"]:
|
|
hard_row["source_composition"] = hard_row.get("source_composition") or hard_row["composition"]
|
|
hard_row["composition"] = hard_composition
|
|
hard_row["composition_prompt"] = _composition_prompt(hard_composition)
|
|
soft_pov_camera_labels = (
|
|
pov_character_labels
|
|
if options["softcore_cast"] == "same_as_hardcore"
|
|
else []
|
|
)
|
|
soft_camera_scene_directive, soft_camera_config = _camera_scene_directive_for_context(
|
|
soft_row.get("scene_text"),
|
|
soft_row.get("composition"),
|
|
soft_camera_config,
|
|
soft_pov_camera_labels,
|
|
soft_subject_kind,
|
|
)
|
|
hard_camera_scene_directive, hard_camera_config = _camera_scene_directive_for_context(
|
|
hard_scene,
|
|
hard_composition,
|
|
hard_camera_config,
|
|
pov_character_labels,
|
|
hard_subject_kind,
|
|
)
|
|
if soft_pov_camera_labels:
|
|
soft_camera_directive = ""
|
|
if pov_character_labels:
|
|
hard_camera_directive = ""
|
|
soft_row["camera_config"] = soft_camera_config
|
|
soft_row["camera_directive"] = soft_camera_directive
|
|
soft_row["camera_scene_directive"] = soft_camera_scene_directive
|
|
hard_row["camera_config"] = hard_camera_config
|
|
hard_row["camera_directive"] = hard_camera_directive
|
|
hard_row["camera_scene_directive"] = hard_camera_scene_directive
|
|
soft_camera_scene_sentence = f"{soft_camera_scene_directive} " if soft_camera_scene_directive else ""
|
|
hard_camera_scene_sentence = f"{hard_camera_scene_directive} " if hard_camera_scene_directive else ""
|
|
soft_camera_sentence = f"Camera control: {soft_camera_directive} " if soft_camera_directive else ""
|
|
hard_camera_sentence = f"Camera control: {hard_camera_directive} " if hard_camera_directive else ""
|
|
soft_cast = (
|
|
"solo creator setup with Woman A alone"
|
|
if options["softcore_cast"] == "solo"
|
|
else f"soft creator-teaser setup with {_insta_of_cast_phrase(hard_women_count, hard_men_count)}"
|
|
)
|
|
soft_cast_presence = (
|
|
(
|
|
"Frame Woman A from the POV participant's first-person camera in a soft creator-teaser setup; "
|
|
"keep the POV participant off-camera as the viewpoint and implied by camera perspective or foreground cues. "
|
|
)
|
|
if options["softcore_cast"] == "same_as_hardcore" and pov_character_labels
|
|
else (
|
|
"Place Woman A and the listed partners together in a soft creator-teaser pose. "
|
|
if options["softcore_cast"] == "same_as_hardcore"
|
|
else "Keep the softcore version focused on Woman A alone. "
|
|
)
|
|
)
|
|
soft_cast_styling_sentence = (
|
|
f"Partner softcore styling: {soft_partner_outfit_text}. Cast pose: {soft_partner_styling['pose']}. "
|
|
if options["softcore_cast"] == "same_as_hardcore" and soft_partner_outfit_text
|
|
else ""
|
|
)
|
|
hard_cast = _insta_of_cast_phrase(hard_women_count, hard_men_count)
|
|
character_hardcore_clothing_entries = _character_hardcore_clothing_entries(
|
|
character_slot_map,
|
|
hard_women_count,
|
|
hard_men_count,
|
|
pov_character_labels,
|
|
hard_content_rng,
|
|
)
|
|
access_flags = _hardcore_row_access_flags(hard_row)
|
|
woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else ""
|
|
default_man_hardcore_clothing_entries = _default_man_hardcore_clothing_entries(
|
|
hard_men_count,
|
|
pov_character_labels,
|
|
character_hardcore_clothing_entries,
|
|
hard_content_rng,
|
|
access_flags["man_lower"],
|
|
)
|
|
has_primary_hardcore_clothing = any(entry.startswith("Woman A") for entry in character_hardcore_clothing_entries)
|
|
fallback_hard_clothing_state = "" if has_primary_hardcore_clothing else _insta_of_hardcore_clothing_state(
|
|
options["hardcore_clothing_continuity"],
|
|
soft_row["item"],
|
|
woman_access=woman_access,
|
|
)
|
|
hard_clothing_parts = [
|
|
part.strip().rstrip(".")
|
|
for part in (
|
|
fallback_hard_clothing_state,
|
|
*character_hardcore_clothing_entries,
|
|
*default_man_hardcore_clothing_entries,
|
|
)
|
|
if str(part or "").strip()
|
|
]
|
|
hard_clothing_state = "; ".join(hard_clothing_parts)
|
|
hard_clothing_sentence = f"{hard_clothing_state}. " if hard_clothing_state else ""
|
|
if "body is fully exposed" in hard_clothing_state.lower() or "bare skin unobstructed" in hard_clothing_state.lower():
|
|
hard_scene = _body_exposure_scene_text(hard_scene)
|
|
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
|
|
hard_row["scene_text"] = hard_scene
|
|
hard_detail_density = options["hardcore_detail_density"]
|
|
hard_detail_directive = {
|
|
"compact": "Use one compact position-first sexual action sentence; avoid repeated aftermath wording. ",
|
|
"balanced": "",
|
|
"dense": "Use dense but coherent motion, contact, and aftermath detail while keeping one readable body position. ",
|
|
}[hard_detail_density]
|
|
pov_directive = _pov_prompt_directive(pov_character_labels)
|
|
soft_descriptor_sentence = (
|
|
f"Cast descriptors: {soft_cast_descriptor_text}. "
|
|
if options["softcore_cast"] == "same_as_hardcore"
|
|
else f"Woman A: {descriptor}. "
|
|
)
|
|
|
|
soft_prompt = (
|
|
f"Insta/OF softcore mode: {platform_style}. "
|
|
f"{soft_descriptor_sentence}"
|
|
f"Softcore setup: {soft_level}. Cast: {soft_cast}. "
|
|
f"{soft_cast_presence}"
|
|
f"{soft_cast_styling_sentence}"
|
|
f"{soft_row['softcore_item_prompt_label']}: {soft_row['item']}. Pose: {soft_row['pose']}. Setting: {soft_row['scene_text']}. "
|
|
f"{soft_camera_scene_sentence}"
|
|
f"{_labeled_expression_sentence('Facial expression', soft_row.get('expression'))}"
|
|
f"Composition: {soft_row['composition']}. "
|
|
f"{soft_camera_sentence}"
|
|
"Keep the softcore version seductive, creator-shot, and styled as a soft teaser. "
|
|
f"{soft_row['positive_suffix']}."
|
|
)
|
|
hard_prompt = (
|
|
f"Insta/OF hardcore mode: {platform_style}. "
|
|
f"Hardcore setup: {hard_level}. Cast: {hard_cast}. "
|
|
f"Cast descriptors: {cast_descriptor_text}. "
|
|
f"{pov_directive + ' ' if pov_directive else ''}"
|
|
f"{'Keep Woman A visually central from the POV camera. ' if pov_character_labels else 'Keep Woman A visually central. '}"
|
|
f"{hard_clothing_sentence}"
|
|
f"Role graph: {hard_row['role_graph']} Sexual scene: {hard_row['item']}. "
|
|
f"Setting: {hard_scene}. "
|
|
f"{hard_camera_scene_sentence}"
|
|
f"{_labeled_expression_sentence('Facial expressions', hard_row.get('expression'))}"
|
|
f"Composition: {hard_composition}. "
|
|
f"{hard_detail_directive}"
|
|
f"{hard_camera_sentence}"
|
|
f"{hard_row['positive_suffix']}."
|
|
)
|
|
if extra_positive.strip():
|
|
soft_prompt = f"{soft_prompt.rstrip()} {extra_positive.strip()}"
|
|
hard_prompt = f"{hard_prompt.rstrip()} {extra_positive.strip()}"
|
|
|
|
soft_prompt = _insta_of_active_trigger(soft_prompt, active_trigger, bool(prepend_trigger_to_prompt))
|
|
hard_prompt = _insta_of_active_trigger(hard_prompt, active_trigger, bool(prepend_trigger_to_prompt))
|
|
soft_prompt = sanitize_prompt_text(soft_prompt, triggers=(active_trigger,))
|
|
hard_prompt = sanitize_prompt_text(hard_prompt, triggers=(active_trigger,))
|
|
soft_negative = sanitize_negative_text(_combined_negative(INSTA_OF_SOFT_NEGATIVE, extra_negative))
|
|
hard_negative = sanitize_negative_text(_combined_negative(INSTA_OF_NEGATIVE, extra_negative))
|
|
soft_caption_parts = [
|
|
active_trigger,
|
|
"Insta/OF softcore mode",
|
|
descriptor,
|
|
soft_level,
|
|
soft_row["item"],
|
|
soft_row["pose"],
|
|
soft_partner_outfit_text,
|
|
soft_partner_styling["pose"],
|
|
soft_row["scene_text"],
|
|
soft_camera_scene_directive,
|
|
soft_row["composition"],
|
|
_camera_caption_text(soft_camera_config) if soft_camera_directive else "",
|
|
]
|
|
soft_caption = sanitize_caption_text(
|
|
", ".join(str(part).strip() for part in soft_caption_parts if str(part).strip()),
|
|
triggers=(active_trigger,),
|
|
)
|
|
hard_caption_parts = [
|
|
active_trigger,
|
|
"Insta/OF hardcore mode",
|
|
"Woman A",
|
|
descriptor,
|
|
hard_cast,
|
|
hard_row["role_graph"],
|
|
hard_row["item"],
|
|
hard_scene,
|
|
hard_camera_scene_directive,
|
|
hard_composition,
|
|
_camera_caption_text(hard_camera_config) if hard_camera_directive else "",
|
|
]
|
|
hard_caption = sanitize_caption_text(
|
|
", ".join(str(part).strip() for part in hard_caption_parts if str(part).strip()),
|
|
triggers=(active_trigger,),
|
|
)
|
|
metadata = {
|
|
"mode": "Insta/OF",
|
|
"options": options,
|
|
"shared_descriptor": descriptor,
|
|
"shared_cast_descriptors": cast_descriptors,
|
|
"pov_character_labels": pov_character_labels,
|
|
"pov_prompt_directive": pov_directive,
|
|
"softcore_partner_styling": soft_partner_styling,
|
|
"character_hardcore_clothing": character_hardcore_clothing_entries,
|
|
"default_man_hardcore_clothing": default_man_hardcore_clothing_entries,
|
|
"hardcore_clothing_state": hard_clothing_state,
|
|
"hardcore_detail_density": hard_detail_density,
|
|
"hardcore_position_config": hard_row.get("hardcore_position_config", {}),
|
|
"softcore_prompt": soft_prompt,
|
|
"hardcore_prompt": hard_prompt,
|
|
"softcore_negative_prompt": soft_negative,
|
|
"hardcore_negative_prompt": hard_negative,
|
|
"softcore_caption": soft_caption,
|
|
"hardcore_caption": hard_caption,
|
|
"softcore_row": soft_row,
|
|
"hardcore_row": hard_row,
|
|
"hardcore_women_count": hard_women_count,
|
|
"hardcore_men_count": hard_men_count,
|
|
"character_cast_slots": character_slots,
|
|
"character_slot_labels": sorted(character_slot_map),
|
|
"softcore_camera_config": soft_camera_config,
|
|
"hardcore_camera_config": hard_camera_config,
|
|
"softcore_camera_directive": soft_camera_directive,
|
|
"hardcore_camera_directive": hard_camera_directive,
|
|
"softcore_camera_scene_directive": soft_camera_scene_directive,
|
|
"hardcore_camera_scene_directive": hard_camera_scene_directive,
|
|
}
|
|
return metadata
|