Files
ComfyUI-Ethanfel-Prompt-Bui…/prompt_builder.py
T

1382 lines
49 KiB
Python

from __future__ import annotations
import json
import random
from pathlib import Path
from string import Formatter
from typing import Any
try:
from . import generate_prompt_batches as g
except ImportError: # Allows local smoke tests with `python -c`.
import generate_prompt_batches as g
ROOT_DIR = Path(__file__).resolve().parent
CATEGORY_DIR = ROOT_DIR / "categories"
BUILTIN_CATEGORIES = [
"auto_weighted",
"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"),
}
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: vertical {composition}. {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: vertical {composition}. {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: vertical {composition}. "
"{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."
)
_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 _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 _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 < 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 cock in pussy and one cock 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 = (
" cock",
"cock ",
"cocks",
"cum",
"creampie",
"facial",
"blowjob",
"fellatio",
"deepthroat",
"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 "cocks" 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
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 _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}
axis_values = {
name: _entry_text(_weighted_choice(rng, _compatible_entries(axes[name], women_count, men_count)))
for name in fields
if name in axes and axes[name]
}
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_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 _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 _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 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,
) -> str:
return json.dumps(
{
"category_seed": int(category_seed),
"subcategory_seed": int(subcategory_seed),
"content_seed": int(content_seed),
"person_seed": int(person_seed),
"scene_seed": int(scene_seed),
"pose_seed": int(pose_seed),
"role_seed": int(role_seed),
"expression_seed": int(expression_seed),
"composition_seed": int(composition_seed),
},
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 _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 _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 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 standard_ratio is None:
return poses
return "standard" if rng.random() < standard_ratio else "evocative"
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 _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 _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]]:
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:
if not _compatible_entry(subcategory, women_count, men_count):
raise ValueError(
f"Subcategory '{subcategory['name']}' is not compatible with "
f"women_count={women_count}, men_count={men_count}"
)
return category, subcategory
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
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 _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 == "asian" 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": f"{body} figure with {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 _lettered(prefix: str, count: int) -> list[str]:
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
return [f"{prefix} {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,
) -> 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()
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)}."
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 "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 or toy 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 or toy 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 "oral" in slug:
graph = f"{a} kneels and takes {b}'s cock 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} climaxes over {b}'s body while {b} keeps eye contact and one hand on his cock."
else:
graph = f"{a} and {b} keep explicit cock 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 "oral" in slug:
graph = f"{woman} gives oral to {man} while {man} holds her hair and hips."
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} penetrates {woman} while {third} adds a second point of contact from the front."
else:
graph = f"{man} penetrates {woman} while a toy adds a second point of contact."
elif people_count >= 3:
graph = f"{man} penetrates {woman} while {third} gives oral contact from the front."
else:
graph = f"{man} penetrates {woman} anally while keeping her hips held open."
elif "threesome" in slug:
graph = f"{man} penetrates {woman} while {third or any_person({woman, man})} uses mouth or hands on the exposed body."
elif "group" in slug or "orgy" in slug:
graph = f"{man} penetrates {woman} while surrounding partners give oral contact and keep hands on hips, breasts, and thighs."
elif "cumshot" in slug or "climax" in slug:
graph = f"{man} climaxes on {woman}'s body while {woman} stays posed with thighs open and direct eye contact."
else:
graph = f"{man} and {woman} keep penetration and body contact visible."
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 = g.choose(rng, g.COUPLE_TYPES)
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,
}
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) -> list[Any]:
fallback = g.GROUP_SCENES if subject_type in ("group", "configured_cast") else g.SCENES
return _list_from(_merged_field(category, subcategory, item, "scenes", fallback))
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) -> list[Any]:
configured = _merged_field(category, subcategory, item, "compositions")
if configured:
return _list_from(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],
) -> 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)
category, subcategory = _find_subcategory(
categories,
category_choice,
subcategory_choice,
category_rng,
subcategory_rng,
women_count,
men_count,
)
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)
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)
subject_type = context["subject_type"]
role_graph = _role_graph(role_rng, subcategory, context, item_axis_values)
scene_slug, scene = _choose_pair(scene_rng, _compatible_entries(_scene_pool(category, subcategory, item, subject_type), women_count, men_count))
pose = str(_merged_field(category, subcategory, item, "pose", "") or context.get("fallback_pose") or _choose_text(
pose_rng, _compatible_entries(_pose_pool(category, subcategory, item, subject_type, poses), women_count, men_count)
))
expression = _choose_text(
expression_rng,
_compatible_entries(_list_from(_merged_field(category, subcategory, item, "expressions", g.EXPRESSIONS)), women_count, men_count),
)
if subject_type in ("couple", "group") and ";" not in expression:
expression = f"{expression}; {_choose_text(expression_rng, g.EXPRESSIONS)}"
composition = _choose_text(
composition_rng,
_compatible_entries(_composition_pool(category, subcategory, item, subject_type), women_count, men_count),
)
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,
"composition": composition,
"role_graph": role_graph,
"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)
caption = _format(caption_template, context)
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,
"custom_item": item_name,
"item_axis_values": item_axis_values,
"scene_text": scene,
"pose": pose,
"seed_config": seed_config,
"content_seed_axis": content_axis,
"role_graph": role_graph,
"cast_summary": context.get("cast_summary", ""),
"scene_kind": context.get("scene_kind", ""),
"women_count": context.get("women_count", ""),
"men_count": context.get("men_count", ""),
"person_count": context.get("person_count", ""),
"source": "json_category",
}
)
if context.get("figure"):
row["figure"] = context["figure"]
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,
) -> dict[str, Any]:
apply_pool_extensions()
row_number = max(1, int(row_number))
start_index = max(1, int(start_index))
seed = int(seed)
clothing = clothing if clothing in ("full", "minimal") else "full"
ethnicity = ethnicity if ethnicity in ("any", "asian", "white_asian") else "any"
poses = poses if poses in ("standard", "evocative") else "standard"
figure = figure if figure in ("curvy", "balanced", "bombshell") else "curvy"
minimal_ratio = _ratio_or_none(minimal_clothing_ratio)
pose_ratio = _ratio_or_none(standard_pose_ratio)
parsed_seed_config = _parse_seed_config(seed_config)
exact_custom_subcategory = bool(subcategory and subcategory != RANDOM_SUBCATEGORY and " / " in subcategory)
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,
)
if extra_positive.strip():
row["prompt"] = f"{row['prompt'].rstrip()} {extra_positive.strip()}"
active_trigger = trigger.strip() or g.TRIGGER
row["prompt"] = _prepend_trigger(row["prompt"], active_trigger, bool(prepend_trigger_to_prompt))
row["negative_prompt"] = _combined_negative(row.get("negative_prompt", g.NEGATIVE_PROMPT), extra_negative)
row["trigger"] = active_trigger
return row