Initial ComfyUI prompt builder nodes

This commit is contained in:
2026-06-24 09:24:51 +02:00
commit d342b41810
10 changed files with 8418 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
+81
View File
@@ -0,0 +1,81 @@
# Improvement Path
## Done
- ComfyUI prompt node.
- JSON-defined main categories and subcategories.
- Compositional item generators with `item_templates` and `item_axes`.
- Softcore/custom clothing categories.
- Explicit erotic clothing category.
- Hardcore sexual-pose category.
- Configurable cast counts with `women_count` and `men_count`.
- Per-axis seed control through `SxCP Seed Control`.
- Cast-aware filtering for subcategories, templates, and axis values.
- Role graph generation for configured hardcore casts.
## Highest-Value Next Steps
1. Explicitness preset
Add a node input like:
- `softcore`
- `nude`
- `explicit`
- `hardcore`
Then categories can share the same cast/person/scene system while swapping
the pose/content pools and negative prompts.
2. Anatomy clarity axis
Add a controlled axis for visual clarity:
- full-body view
- hips-focused view
- genital-contact view
- face-and-body view
- mirror view
This helps hardcore outputs read as sex scenes instead of vague tangled
bodies.
3. Outfit and pose compatibility
Hardcore pose categories should optionally pull from erotic clothing or nude
accessory categories. Add an input or template field for:
- clothed sex
- lingerie sex
- nude sex
- fetishwear sex
- wet/shower sex
4. More seed/reroll utility nodes
Add tiny helper nodes:
- `SxCP Reroll Pose Seed`
- `SxCP Reroll Scene Seed`
- `SxCP Reroll Person Seed`
These can output a modified `seed_config` while preserving the other locked
seeds.
5. Validation and preview tools
Add a local validator that reports:
- category and subcategory counts
- template placeholder errors
- axis size and variation count
- impossible cast/template combinations
- missing scene/pose/expression pools
## Hardcore-Specific Improvement Order
1. Split hardcore into act families with deeper compatibility rules.
2. Add explicitness preset and prompt-strength controls.
3. Add anatomy/camera clarity axis.
4. Add outfit-state control for nude/lingerie/fetish/clothed sex.
5. Add validation so impossible prompts are caught before ComfyUI generation.
+278
View File
@@ -0,0 +1,278 @@
# ComfyUI Prompt Builder
Custom ComfyUI node for building prompts from the existing `generate_prompt_batches.py`
generator without writing image batches.
The node is registered as:
- `prompt_builder / SxCP Prompt Builder`
- `prompt_builder / SxCP Seed Control`
- `prompt_builder / SxCP Caption Naturalizer`
It outputs:
- `prompt`
- `negative_prompt`
- `caption`
- `metadata_json`
- `category`
- `subcategory`
`SxCP Seed Control` outputs `seed_config`, which can be connected to the prompt
builder's optional `seed_config` input.
`SxCP Caption Naturalizer` rewrites tag-like captions or labeled prompts into
more natural language. Connect the prompt builder's `metadata_json` output to
`source_text` for the cleanest result. You can also connect `caption` or
`prompt`; in that case the node falls back to prompt-label parsing or comma-tag
cleanup.
Naturalizer controls:
- `input_hint`: `auto`, `metadata_json`, or `caption_or_prompt`.
- `detail_level`: `concise`, `balanced`, or `dense`.
- `style_policy`: `drop_style_tail` removes old fixed style tails; `keep_style_terms`
keeps style descriptions in the rewritten text.
- `trigger`: defaults to `sxcppnl7`.
- `include_trigger`: prepends the trigger as its own sentence.
It outputs:
- `natural_caption`
- `method`
## Built-In Categories
The node keeps the original generator controls:
- `category`: `auto_weighted`, `woman`, `man`, `couple`, `group_or_layout`, or a custom JSON category.
- `clothing`: `full` or `minimal`.
- `minimal_clothing_ratio`: `-1` disables mixing; `0.0` to `1.0` mixes minimal/full clothing.
- `ethnicity`: `any`, `asian`, `white_asian`.
- `poses`: `standard` or `evocative`.
- `standard_pose_ratio`: `-1` disables mixing; `0.0` to `1.0` mixes standard/evocative poses.
- `backside_bias`: `0.0` to `1.0`, applies to evocative single-subject poses.
- `figure`: `curvy`, `balanced`, `bombshell`.
- `no_plus_women`: excludes plus-size women.
- `no_black`: excludes Black/African-coded women from women-focused pools.
`auto_weighted` uses the original batch mix: mostly women, then men, couples, and
group/layout rows. Direct categories generate only that selected category.
## Custom Categories
Add or edit JSON files in `categories/*.json`. Each file can define new main
categories, subcategories, and optional extensions to the built-in pools. Restart
or reload ComfyUI after changing JSON so dropdown choices are rebuilt.
Included JSON categories:
- `Casual clothes`
- `Provocative erotic clothes`
- `Hardcore sexual poses`
Example:
```json
{
"version": 1,
"categories": [
{
"name": "Casual clothes",
"slug": "casual_clothes",
"subject_type": "woman",
"item_label": "Clothing",
"style": "tasteful adult fashion-editorial coloured-pencil comic illustration",
"subcategories": [
{
"name": "Streetwear",
"slug": "streetwear",
"items": [
"oversized hoodie with slim jeans and clean sneakers",
"cropped bomber jacket with cargo pants and chunky trainers"
],
"scenes": [
{
"slug": "city_crosswalk",
"prompt": "sunlit city crosswalk with storefront reflections"
}
],
"poses": [
"standing with one hand in a pocket",
"walking forward with a casual runway stride"
]
}
]
}
]
}
```
Custom categories do not need a Python generator. If no `prompt_template` is
provided, the node uses a generic composer that selects subject appearance,
scene, pose, expression, composition, and a random item from the selected
subcategory.
For large categories, prefer `item_templates` plus `item_axes` instead of writing
every final item by hand:
```json
{
"name": "Example clothes",
"subject_type": "woman",
"subcategories": [
{
"name": "Lingerie",
"item_templates": [
"{color} {fabric} {top} with {bottom} and {stockings}"
],
"item_axes": {
"color": ["black", "red", "ivory"],
"fabric": ["lace", "satin", "transparent mesh"],
"top": ["balconette bra", "open-cup bra"],
"bottom": ["matching thong", "high-cut g-string"],
"stockings": ["thigh-high stockings", "fishnet stockings"]
}
}
]
}
```
The node chooses one template, fills each placeholder from the matching axis,
then records the selected axis values in `metadata_json`.
Supported `subject_type` values:
- `single_any`
- `woman`
- `man`
- `couple`
- `group`
- `layout`
- `scene`
- `configured_cast`
`configured_cast` uses the node's `women_count` and `men_count` inputs. It adds
template fields for `{women_count}`, `{men_count}`, `{person_count}`,
`{cast_summary}`, `{scene_kind}`, and `{role_graph}`. This is intended for
categories where the same prompt generator should support couples, threesomes,
and larger groups.
`{role_graph}` is a generated choreography sentence that assigns roles to the
cast, such as who penetrates, who receives oral, and who joins from the side.
It is currently most useful for `Hardcore sexual poses`.
Subcategories, templates, and axis values can declare cast constraints:
```json
{
"name": "Threesomes",
"min_people": 3,
"item_axes": {
"act": [
{
"text": "strap-on penetration and cunnilingus",
"cast": "women_only"
},
{
"text": "male/male oral and anal contact",
"cast": "men_only"
},
{
"text": "front-and-back penetration",
"cast": "mixed"
}
]
}
}
```
Supported constraints:
- `min_women`, `max_women`
- `min_men`, `max_men`
- `min_people`, `max_people`
- `cast` or `requires`: `women_only`, `men_only`, `mixed`, `has_women`,
`has_men`, `solo`, `couple`, `threesome`, `group`
If an exact subcategory is not compatible with `women_count` and `men_count`,
the node raises a clear error instead of generating an impossible prompt.
Use the `subcategory` dropdown to select either `random` or an exact
`Main category / Subcategory` path. Exact paths override the `category` dropdown,
which is useful because ComfyUI does not provide dependent dropdowns from Python
alone.
## Seed Control
The main `seed` input is still the default master seed. Connect `SxCP Seed
Control` to `seed_config` when you want to lock or vary specific axes.
Seed values:
- `-1`: follow the main seed.
- `0` or higher: override only that axis.
Axes:
- `category_seed`: custom category selection when `custom_random` is used.
- `subcategory_seed`: random subcategory selection.
- `content_seed`: generated item content, such as outfit wording.
- `person_seed`: appearance/person selection.
- `scene_seed`: scene/environment selection.
- `pose_seed`: body pose selection. For `Hardcore sexual poses`, this also drives the generated sexual pose content.
- `role_seed`: participant choreography for `{role_graph}`. If left at `-1`, it follows `pose_seed`.
- `expression_seed`: facial expression selection.
- `composition_seed`: camera/composition selection.
Example workflow: if the person and scene are right but the pose is wrong, keep
`person_seed` and `scene_seed` fixed, then change `pose_seed`. If the cast roles
are wrong but the act wording is good, change `role_seed`. If the clothing or
sexual act wording is wrong, change `content_seed`; for pose-driven categories,
change `pose_seed`.
## Pool Extensions
Use `pool_extensions` to add new entries to built-in pools without editing
Python:
```json
{
"pool_extensions": {
"women_clothes": ["relaxed high-waist jeans with a fitted ribbed tank top"],
"men_clothes": ["clean white T-shirt with relaxed jeans and canvas sneakers"],
"poses": ["standing with a relaxed hand-on-hip pose"],
"expressions": ["easygoing half-smile"],
"scenes": [
{
"slug": "city_cafe",
"prompt": "quiet city cafe terrace with morning light and small round tables"
}
]
}
}
```
Known extension pools:
`women_clothes`, `women_clothes_minimal`, `men_clothes`, `men_clothes_minimal`,
`couple_outfits`, `couple_outfits_minimal`, `poses`, `evocative_poses`,
`backside_poses`, `expressions`, `compositions`, `props`, `figure_curvy`,
`figure_athletic`, `figure_bombshell`, `scenes`, `group_scenes`,
`layouts_full`, `layouts_minimal`, `group_compositions`, `group_ages`.
## Templates
A category, subcategory, or individual item can provide `prompt_template` and
`caption_template`. Templates can use these fields:
`{trigger}`, `{main_category}`, `{subcategory}`, `{item}`, `{item_label}`,
`{subject}`, `{subject_phrase}`, `{age}`, `{body}`, `{body_phrase}`, `{skin}`,
`{hair}`, `{eyes}`, `{figure}`, `{scene}`, `{pose}`, `{expression}`,
`{composition}`, `{style}`, `{positive_suffix}`, `{negative_prompt}`,
`{women_count}`, `{men_count}`, `{person_count}`, `{cast_summary}`,
`{scene_kind}`, `{role_graph}`.
If `prepend_trigger_to_prompt` is enabled, the node prepends the trigger to the
positive prompt. Disable it for output closer to the original script's `prompt`
field.
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import json
try:
from .prompt_builder import build_prompt, build_seed_config_json, category_choices, subcategory_choices
from .caption_naturalizer import naturalize_caption
except ImportError:
from prompt_builder import build_prompt, build_seed_config_json, category_choices, subcategory_choices
from caption_naturalizer import naturalize_caption
class SxCPPromptBuilder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"category": (category_choices(), {"default": "auto_weighted"}),
"subcategory": (subcategory_choices(), {"default": "random"}),
"row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}),
"start_index": ("INT", {"default": 41, "min": 1, "max": 1000000, "step": 1}),
"seed": ("INT", {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1}),
"clothing": (["full", "minimal"], {"default": "full"}),
"ethnicity": (["any", "asian", "white_asian"], {"default": "any"}),
"poses": (["standard", "evocative"], {"default": "standard"}),
"backside_bias": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"figure": (["curvy", "balanced", "bombshell"], {"default": "curvy"}),
"no_plus_women": ("BOOLEAN", {"default": False}),
"no_black": ("BOOLEAN", {"default": False}),
"women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
"men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
"minimal_clothing_ratio": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"standard_pose_ratio": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
"trigger": ("STRING", {"default": "sxcpinup_coloredpencil"}),
"prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}),
},
"optional": {
"seed_config": ("STRING", {"default": "", "multiline": True}),
"extra_positive": ("STRING", {"default": "", "multiline": True}),
"extra_negative": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("prompt", "negative_prompt", "caption", "metadata_json", "category", "subcategory")
FUNCTION = "build"
CATEGORY = "prompt_builder"
def build(
self,
category,
subcategory,
row_number,
start_index,
seed,
clothing,
ethnicity,
poses,
backside_bias,
figure,
no_plus_women,
no_black,
women_count,
men_count,
minimal_clothing_ratio,
standard_pose_ratio,
trigger,
prepend_trigger_to_prompt,
seed_config="",
extra_positive="",
extra_negative="",
):
row = build_prompt(
category=category,
subcategory=subcategory,
row_number=row_number,
start_index=start_index,
seed=seed,
clothing=clothing,
ethnicity=ethnicity,
poses=poses,
backside_bias=backside_bias,
figure=figure,
no_plus_women=no_plus_women,
no_black=no_black,
women_count=women_count,
men_count=men_count,
minimal_clothing_ratio=minimal_clothing_ratio,
standard_pose_ratio=standard_pose_ratio,
trigger=trigger,
prepend_trigger_to_prompt=prepend_trigger_to_prompt,
extra_positive=extra_positive or "",
extra_negative=extra_negative or "",
seed_config=seed_config or "",
)
return (
row["prompt"],
row["negative_prompt"],
row["caption"],
json.dumps(row, ensure_ascii=True, sort_keys=True),
row.get("main_category", category),
row.get("subcategory", subcategory),
)
class SxCPSeedControl:
@classmethod
def INPUT_TYPES(cls):
seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}
return {
"required": {
"category_seed": ("INT", seed_spec),
"subcategory_seed": ("INT", seed_spec),
"content_seed": ("INT", seed_spec),
"person_seed": ("INT", seed_spec),
"scene_seed": ("INT", seed_spec),
"pose_seed": ("INT", seed_spec),
"role_seed": ("INT", seed_spec),
"expression_seed": ("INT", seed_spec),
"composition_seed": ("INT", seed_spec),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("seed_config",)
FUNCTION = "build"
CATEGORY = "prompt_builder"
def build(
self,
category_seed,
subcategory_seed,
content_seed,
person_seed,
scene_seed,
pose_seed,
role_seed,
expression_seed,
composition_seed,
):
return (
build_seed_config_json(
category_seed=category_seed,
subcategory_seed=subcategory_seed,
content_seed=content_seed,
person_seed=person_seed,
scene_seed=scene_seed,
pose_seed=pose_seed,
role_seed=role_seed,
expression_seed=expression_seed,
composition_seed=composition_seed,
),
)
class SxCPCaptionNaturalizer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_text": ("STRING", {"default": "", "multiline": True}),
"input_hint": (["auto", "metadata_json", "caption_or_prompt"], {"default": "auto"}),
"detail_level": (["balanced", "concise", "dense"], {"default": "balanced"}),
"style_policy": (["drop_style_tail", "keep_style_terms"], {"default": "drop_style_tail"}),
"trigger": ("STRING", {"default": "sxcppnl7"}),
"include_trigger": ("BOOLEAN", {"default": True}),
},
"optional": {
"metadata_json": ("STRING", {"default": "", "multiline": True}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("natural_caption", "method")
FUNCTION = "build"
CATEGORY = "prompt_builder"
def build(
self,
source_text,
input_hint,
detail_level,
style_policy,
trigger,
include_trigger,
metadata_json="",
):
return naturalize_caption(
source_text=source_text or "",
metadata_json=metadata_json or "",
input_hint=input_hint,
trigger=trigger,
include_trigger=include_trigger,
detail_level=detail_level,
style_policy=style_policy,
)
NODE_CLASS_MAPPINGS = {
"SxCPPromptBuilder": SxCPPromptBuilder,
"SxCPSeedControl": SxCPSeedControl,
"SxCPCaptionNaturalizer": SxCPCaptionNaturalizer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPPromptBuilder": "SxCP Prompt Builder",
"SxCPSeedControl": "SxCP Seed Control",
"SxCPCaptionNaturalizer": "SxCP Caption Naturalizer",
}
+565
View File
@@ -0,0 +1,565 @@
from __future__ import annotations
import json
import re
from typing import Any
OLD_TRIGGER = "sxcpinup_coloredpencil"
DEFAULT_TRIGGER = "sxcppnl7"
STYLE_TAILS = [
", coloured pencil comic illustration, crisp linework, hatching, soft pastel palette, warm sensual lighting, textured parchment paper",
", coloured pencil comic illustration, crisp linework, hatching, soft pastel palette, warm sensual lighting, textured paper",
]
PROMPT_FIELD_LABELS = (
"Ages",
"Body types",
"Cast",
"Scene",
"Setting",
"Pose",
"Sexual pose",
"Facial expression",
"Facial expressions",
"Clothing",
"Erotic outfit",
"Prop/detail",
"Composition",
"Role graph",
"Use",
"Avoid",
)
ITEM_LABELS = (
"Sexual pose",
"Erotic outfit",
"Clothing",
)
def _clean_text(value: Any) -> str:
text = "" if value is None else str(value)
text = text.replace("\n", " ")
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"\s+([,.;:])", r"\1", text)
return text
def _cap_first(text: str) -> str:
text = _clean_text(text).strip(" ,")
return text[:1].upper() + text[1:] if text else ""
def _article(noun_phrase: str) -> str:
word = noun_phrase.lstrip().lower()
if word.startswith("hour") or word[:1] in "aeiou":
return "an"
return "a"
def _sentence(text: str) -> str:
text = _clean_text(text).strip(" ,;")
if not text:
return ""
if text[-1] not in ".!?":
text += "."
return _cap_first(text)
def _join_sentences(parts: list[str]) -> str:
return " ".join(part for part in (_sentence(part) for part in parts) if part)
def _human_join(parts: list[str]) -> str:
parts = [part for part in (_clean_text(part) for part in parts) if part]
if len(parts) <= 1:
return "".join(parts)
if len(parts) == 2:
return f"{parts[0]} and {parts[1]}"
return f"{', '.join(parts[:-1])}, and {parts[-1]}"
def _strip_style_tail(text: str) -> str:
text = _clean_text(text)
for tail in STYLE_TAILS:
if text.endswith(tail):
return text[: -len(tail)].strip(" ,")
return text
def _remove_trigger(text: str, trigger: str) -> str:
text = _clean_text(text).strip(" ,")
for candidate in (trigger, OLD_TRIGGER, DEFAULT_TRIGGER):
candidate = candidate.strip()
if not candidate:
continue
if text.lower().startswith(candidate.lower() + ","):
return text[len(candidate) + 1 :].strip(" ,")
if text.lower().startswith(candidate.lower() + "."):
return text[len(candidate) + 1 :].strip(" ,")
if text.lower() == candidate.lower():
return ""
return text
def _with_trigger(text: str, trigger: str, include_trigger: bool) -> str:
text = _join_sentences([text]) if "." not in text else _clean_text(text)
trigger = _clean_text(trigger or DEFAULT_TRIGGER)
if not include_trigger or not trigger:
return text
if text.lower().startswith(trigger.lower() + "."):
return text
return f"{trigger}. {text}"
def _maybe_json(text: str) -> dict[str, Any] | None:
text = _clean_text(text)
if not text or not text.startswith("{"):
return None
try:
value = json.loads(text)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
def _row_from_inputs(source_text: str, metadata_json: str, input_hint: str) -> tuple[dict[str, Any] | None, str]:
candidates: list[tuple[str, str]] = []
if input_hint in ("auto", "metadata_json"):
candidates.append((metadata_json, "metadata_json"))
candidates.append((source_text, "source_json"))
for text, method in candidates:
row = _maybe_json(text)
if row is not None:
return row, method
return None, "text"
def _prompt_field(text: str, label: str) -> str:
text = _clean_text(text)
if not text:
return ""
labels = "|".join(re.escape(name) for name in PROMPT_FIELD_LABELS)
pattern = rf"{re.escape(label)}:\s*(.*?)(?=\. (?:{labels}):|\. Use\b|\. Avoid\b|$)"
match = re.search(pattern, text)
if not match:
return ""
return _clean_text(match.group(1)).rstrip(".")
def _row_value(row: dict[str, Any], key: str, labels: tuple[str, ...] = ()) -> str:
value = _clean_text(row.get(key, ""))
if value:
return value
prompt = _clean_text(row.get("prompt", ""))
for label in labels:
value = _prompt_field(prompt, label)
if value:
return value
return ""
def _field_from_any_prompt(text: str, labels: tuple[str, ...]) -> str:
for label in labels:
value = _prompt_field(text, label)
if value:
return value
return ""
def _normalize_composition(text: str) -> str:
return re.sub(r"^vertical\s+", "", _clean_text(text), flags=re.IGNORECASE)
def _clean_clothing(text: str) -> str:
text = _clean_text(text)
text = re.sub(r",?\s*fashion editorial styling$", "", text, flags=re.IGNORECASE)
text = re.sub(r",?\s*resort styling$", "", text, flags=re.IGNORECASE)
return text.strip(" ,")
def _single_caption_front(row: dict[str, Any]) -> dict[str, str]:
caption = _clean_text(row.get("caption"))
if not caption:
return {}
caption = _remove_trigger(_strip_style_tail(caption), _clean_text(row.get("trigger")) or DEFAULT_TRIGGER)
caption = _remove_trigger(caption, OLD_TRIGGER)
subject = _clean_text(row.get("primary_subject"))
age = _clean_text(row.get("age_band") or row.get("age"))
body_phrase = _clean_text(row.get("body_phrase"))
if not body_phrase:
body = _clean_text(row.get("body_type") or row.get("body"))
figure = _clean_text(row.get("figure"))
body_phrase = f"{body} figure with {figure}" if body and figure else f"{body} figure".strip()
front = f"{subject}, {age}, {body_phrase}, "
if subject in ("woman", "man") and age and body_phrase and caption.startswith(front):
try:
skin, hair, eyes, _rest = caption[len(front) :].split(", ", 3)
except ValueError:
return {}
else:
pieces = [piece.strip() for piece in caption.split(", ", 6)]
if len(pieces) < 7:
return {}
subject, age, body_phrase, skin, hair, eyes, _rest = pieces
if subject not in ("woman", "man"):
return {}
return {
"caption_subject": subject,
"caption_age": age,
"caption_body_phrase": body_phrase,
"caption_skin": skin,
"caption_hair": hair,
"caption_eyes": eyes,
}
def _pose_clause(pose: str) -> str:
pose = _clean_text(pose)
if not pose:
return ""
first = pose.split(None, 1)[0].lower()
if first.endswith("ing") or first in ("seated", "reclined", "posed"):
return pose
return f"posing in {pose}"
def _age_subject(age: str, subject: str) -> str:
age = _clean_text(age)
subject = _clean_text(subject) or "person"
if not age:
return f"An adult {subject}"
clean_age = re.sub(r"\s+adults?$", "", age).strip()
if "year-old" in clean_age:
return f"A {clean_age} adult {subject}"
if re.search(r"\d", clean_age):
poss = "her" if subject == "woman" else "his"
return f"An adult {subject} in {poss} {clean_age}"
return f"An adult {clean_age} {subject}"
def _clean_age_phrase(age: str) -> str:
age = _clean_text(age)
age = re.sub(r"\s+adults?$", "", age).strip()
return age.replace("-year-old", " years old")
def _subject_phrase_from_counts(row: dict[str, Any]) -> str:
subject = _clean_text(row.get("subject_phrase"))
if subject:
return subject
try:
women = int(row.get("women_count") or 0)
men = int(row.get("men_count") or 0)
except (TypeError, ValueError):
return _clean_text(row.get("primary_subject")) or "adult scene"
parts = []
if women:
parts.append(f"{women} adult {'woman' if women == 1 else 'women'}")
if men:
parts.append(f"{men} adult {'man' if men == 1 else 'men'}")
if not parts:
return _clean_text(row.get("primary_subject")) or "adult scene"
return " and ".join(parts)
def _verb_for_row(row: dict[str, Any]) -> str:
try:
return "is" if int(row.get("person_count") or 0) == 1 else "are"
except (TypeError, ValueError):
return "are"
def _detail_allows(level: str, dense_only: bool = False) -> bool:
level = (level or "balanced").strip().lower()
if dense_only:
return level == "dense"
return level != "concise"
def _single_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
subject = _clean_text(row.get("primary_subject") or row.get("subject") or "")
if subject not in ("woman", "man"):
return None
caption_front = _single_caption_front(row)
age = _clean_text(row.get("age") or row.get("age_band") or caption_front.get("caption_age") or "")
body_phrase = _row_value(row, "body_phrase") or caption_front.get("caption_body_phrase", "")
if not body_phrase:
body = _clean_text(row.get("body_type") or row.get("body") or "")
figure = _clean_text(row.get("figure"))
body_phrase = f"{body} figure with {figure}" if body and figure else f"{body} figure".strip()
skin = _row_value(row, "skin") or caption_front.get("caption_skin", "")
hair = _row_value(row, "hair") or caption_front.get("caption_hair", "")
eyes = _row_value(row, "eyes") or caption_front.get("caption_eyes", "")
item = _row_value(row, "item", ITEM_LABELS)
if item:
item = _clean_clothing(item)
if not item:
item = _clean_clothing(_row_value(row, "clothing", ("Clothing", "Erotic outfit")))
scene = _row_value(row, "scene_text", ("Scene", "Setting"))
pose = _row_value(row, "pose", ("Pose",))
expression = _row_value(row, "expression", ("Facial expression", "Facial expressions"))
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
prop = _row_value(row, "prop", ("Prop/detail",))
style = _row_value(row, "style") if keep_style else ""
parts = []
opener = _age_subject(age, subject)
appearance_details = [piece for piece in (skin, hair, eyes) if piece]
if body_phrase:
parts.append(f"{opener} has {_article(body_phrase)} {body_phrase}")
elif appearance_details:
parts.append(f"{opener} has {_human_join(appearance_details)}")
else:
parts.append(opener)
if body_phrase and appearance_details:
parts.append(f"{pronoun(subject)} has {_human_join(appearance_details)}")
if item:
verb = "wears" if subject == "woman" else "is dressed in"
parts.append(f"{pronoun(subject)} {verb} {item}")
if prop:
parts.append(f"{pronoun(subject)} is {prop}")
if pose:
parts.append(f"{pronoun(subject)} is {_pose_clause(pose)}")
if expression:
parts.append(f"{possessive_pronoun(subject)} expression is {expression}")
if scene:
parts.append(f"The setting is {scene}")
if _detail_allows(detail_level) and composition:
parts.append(f"The composition is {composition}")
if keep_style and style:
parts.append(f"The visual style is {style}")
return _join_sentences(parts), "metadata(single)"
def pronoun(subject: str) -> str:
return "She" if subject == "woman" else "He"
def possessive_pronoun(subject: str) -> str:
return "Her" if subject == "woman" else "His"
def _couple_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
subject = _clean_text(row.get("subject_phrase") or row.get("primary_subject"))
primary = _clean_text(row.get("primary_subject"))
if "couple" not in primary and subject not in ("two women", "two men", "a woman and a man"):
if not primary.startswith("two ") and " and " not in subject:
return None
if subject == "woman and man":
subject = "a woman and a man"
ages = _row_value(row, "age", ("Ages",)) or _clean_text(row.get("age_band"))
body = _row_value(row, "body", ("Body types",)) or _clean_text(row.get("body_type"))
pose = _row_value(row, "pose", ("Pose",))
pose = pose.replace(", affectionate and flirtatious but non-explicit", "")
clothing = _clean_clothing(_row_value(row, "item", ITEM_LABELS) or _row_value(row, "clothing", ("Clothing",)))
scene = _row_value(row, "scene_text", ("Scene", "Setting"))
expression = _row_value(row, "expression", ("Facial expressions", "Facial expression"))
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
style = _row_value(row, "style") if keep_style else ""
parts = [f"{_cap_first(subject)} are adults"]
if ages:
parts.append(f"The age detail is {_clean_age_phrase(ages)}")
if body:
parts.append(f"Their body types are {body}")
if clothing:
parts.append(f"They wear {clothing}")
if pose:
parts.append(f"The pose is {pose}")
if scene:
parts.append(f"The setting is {scene}")
if expression:
parts.append(f"Their expressions are {expression}")
if _detail_allows(detail_level) and composition:
parts.append(f"The composition is {composition}")
if keep_style and style:
parts.append(f"The visual style is {style}")
return _join_sentences(parts), "metadata(couple)"
def _configured_cast_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
if _clean_text(row.get("subject_type")) != "configured_cast" and not _clean_text(row.get("women_count")):
if "hardcore sexual poses" not in _clean_text(row.get("main_category")).lower():
return None
subject = _subject_phrase_from_counts(row)
verb = _verb_for_row(row)
cast = _row_value(row, "cast_summary", ("Cast",))
role_graph = _row_value(row, "role_graph", ("Role graph",))
item = _row_value(row, "item", ITEM_LABELS)
scene = _row_value(row, "scene_text", ("Setting", "Scene"))
expression = _row_value(row, "expression", ("Facial expressions", "Facial expression"))
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
scene_kind = _row_value(row, "scene_kind") or "explicit adult sex scene"
style = _row_value(row, "style") if keep_style else ""
parts = [f"{_cap_first(subject)} {verb} shown as a consensual {scene_kind}, with all participants 21+"]
if cast:
parts.append(f"The cast is {cast}")
if role_graph:
parts.append(role_graph)
if item:
parts.append(f"The sexual pose is {item}")
scene_bits = []
if scene:
scene_bits.append(f"set in {scene}")
if expression:
scene_bits.append(f"with {expression}")
if composition:
scene_bits.append(f"framed as {composition}")
if scene_bits and _detail_allows(detail_level):
parts.append(", ".join(scene_bits))
if keep_style and style:
parts.append(f"The visual style is {style}")
return _join_sentences(parts), "metadata(configured_cast)"
def _group_or_layout_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
primary = _clean_text(row.get("primary_subject"))
if "group" not in primary and primary != "layout scene":
return None
subject = _row_value(row, "subject_phrase") or primary
age = _row_value(row, "age", ("Ages",)) or _clean_text(row.get("age_band"))
item = _clean_clothing(_row_value(row, "item", ITEM_LABELS) or _row_value(row, "clothing", ("Clothing",)))
scene = _row_value(row, "scene_text", ("Scene", "Setting"))
expression = _row_value(row, "expression", ("Facial expressions", "Facial expression"))
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
style = _row_value(row, "style") if keep_style else ""
if primary == "layout scene":
parts = [f"{_cap_first(subject)} is arranged as an adults-only designed illustration layout"]
if expression:
parts.append(f"The featured expression is {expression}")
else:
parts = [f"{_cap_first(subject)} includes adults"]
if age:
parts[0] += f" ages {age}"
if item:
parts.append(f"They wear {item}")
if expression:
parts.append(f"They show {expression}")
if scene:
parts.append(f"The setting is {scene}")
if _detail_allows(detail_level) and composition:
parts.append(f"The composition is {composition}")
if keep_style and style:
parts.append(f"The visual style is {style}")
return _join_sentences(parts), "metadata(group_layout)"
def _metadata_to_prose(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str]:
for builder in (
_configured_cast_from_row,
_single_from_row,
_couple_from_row,
_group_or_layout_from_row,
):
result = builder(row, detail_level, keep_style)
if result:
return result
return _text_to_prose(_clean_text(row.get("caption") or row.get("prompt")), detail_level, keep_style)
def _prompt_to_prose(text: str, detail_level: str, keep_style: bool) -> tuple[str, str] | None:
if ":" not in text:
return None
cast = _field_from_any_prompt(text, ("Cast",))
item = _field_from_any_prompt(text, ITEM_LABELS)
scene = _field_from_any_prompt(text, ("Setting", "Scene"))
pose = _field_from_any_prompt(text, ("Pose",))
role_graph = _field_from_any_prompt(text, ("Role graph",))
expression = _field_from_any_prompt(text, ("Facial expressions", "Facial expression"))
composition = _normalize_composition(_field_from_any_prompt(text, ("Composition",)))
if not any((cast, item, scene, pose, role_graph, expression, composition)):
return None
subject = _clean_text(text.split(":", 1)[0])
parts = []
if subject:
parts.append(f"{_cap_first(subject)}")
if cast:
parts.append(f"The cast is {cast}")
if role_graph:
parts.append(role_graph)
if item:
item_label = "sexual pose" if _field_from_any_prompt(text, ("Sexual pose",)) else "key detail"
parts.append(f"The {item_label} is {item}")
elif pose:
parts.append(f"The pose is {pose}")
scene_bits = []
if scene:
scene_bits.append(f"set in {scene}")
if expression:
scene_bits.append(f"with {expression}")
if composition:
scene_bits.append(f"framed as {composition}")
if scene_bits and _detail_allows(detail_level):
parts.append(", ".join(scene_bits))
if keep_style:
style = _clean_text(text.split(":", 1)[1].split(".", 1)[0])
if style:
parts.append(f"The visual style is {style}")
return _join_sentences(parts), "prompt(labels)"
def _parts_to_sentence(parts: list[str], detail_level: str) -> str:
parts = [part for part in (_clean_text(part).strip(" ,.") for part in parts) if part]
if not parts:
return ""
if len(parts) == 1:
return _sentence(parts[0])
subject = parts[0]
trailing_style = ""
if parts[-1].lower().endswith("illustration"):
trailing_style = parts.pop()
composition = parts[-1] if len(parts) >= 2 else ""
scene = parts[-2] if len(parts) >= 3 else ""
details = parts[1:-2] if len(parts) >= 3 else parts[1:]
sentences = [f"{_cap_first(subject)} includes {', '.join(details)}" if details else _cap_first(subject)]
if _detail_allows(detail_level) and scene:
sentences.append(f"The setting is {scene}")
if _detail_allows(detail_level) and composition:
sentences.append(f"The composition is {composition}")
if trailing_style and _detail_allows(detail_level, dense_only=True):
sentences.append(f"The visual style is {trailing_style}")
return _join_sentences(sentences)
def _text_to_prose(text: str, detail_level: str, keep_style: bool) -> tuple[str, str]:
text = _clean_text(text)
prompt_result = _prompt_to_prose(text, detail_level, keep_style)
if prompt_result:
return prompt_result
text = _remove_trigger(_strip_style_tail(text), DEFAULT_TRIGGER)
text = _remove_trigger(text, OLD_TRIGGER)
parts = [part.strip() for part in text.split(",")]
prose = _parts_to_sentence(parts, detail_level)
return prose or _sentence(text), "text(fallback)"
def naturalize_caption(
source_text: str,
metadata_json: str = "",
input_hint: str = "auto",
trigger: str = DEFAULT_TRIGGER,
include_trigger: bool = True,
detail_level: str = "balanced",
style_policy: str = "drop_style_tail",
) -> tuple[str, str]:
"""Rewrite tag-style prompt/caption text into compact natural language."""
input_hint = input_hint if input_hint in ("auto", "metadata_json", "caption_or_prompt") else "auto"
detail_level = detail_level if detail_level in ("concise", "balanced", "dense") else "balanced"
keep_style = style_policy == "keep_style_terms"
row, row_method = _row_from_inputs(source_text, metadata_json, input_hint)
if row is not None:
prose, method = _metadata_to_prose(row, detail_level, keep_style)
return _with_trigger(prose, trigger, include_trigger), f"{row_method}:{method}"
prose, method = _text_to_prose(source_text, detail_level, keep_style)
return _with_trigger(prose, trigger, include_trigger), method
+97
View File
@@ -0,0 +1,97 @@
{
"version": 1,
"categories": [
{
"name": "Casual clothes",
"slug": "casual_clothes",
"weight": 1.0,
"subject_type": "woman",
"item_label": "Clothing",
"style": "tasteful adult fashion-editorial coloured-pencil comic illustration with casual everyday styling",
"positive_suffix": "Use crisp clean comic linework, soft fabric texture, detailed hatching, warm natural light, and tactile textured paper.",
"subcategories": [
{
"name": "Streetwear",
"slug": "streetwear",
"weight": 1.0,
"items": [
"oversized hoodie with slim jeans and clean sneakers",
"cropped bomber jacket with cargo pants and chunky trainers",
"graphic tee under an open flannel with fitted denim",
"sleek track jacket with joggers and minimal sneakers",
"denim-on-denim outfit with a fitted white tee"
],
"scenes": [
{
"slug": "city_crosswalk",
"prompt": "sunlit city crosswalk with storefront reflections"
},
{
"slug": "subway_platform",
"prompt": "clean subway platform with tiled walls and soft overhead lights"
}
],
"poses": [
"standing with one hand in a pocket",
"leaning against a wall with relaxed confidence",
"walking forward with a casual runway stride"
]
},
{
"name": "Summer casual",
"slug": "summer_casual",
"weight": 1.0,
"items": [
"light cotton sundress with simple sandals",
"linen shorts with a tucked-in sleeveless blouse",
"soft camp-collar shirt with relaxed trousers",
"ribbed tank top with a flowing midi skirt",
"breathable linen two-piece in pale summer colors"
],
"scenes": [
{
"slug": "sunny_market",
"prompt": "open-air weekend market with fruit stands and canvas awnings"
},
{
"slug": "seaside_walk",
"prompt": "seaside promenade with bright sky and warm paving stones"
}
],
"poses": [
"turning slightly with fabric moving in the breeze",
"standing in warm sunlight with relaxed shoulders",
"sitting on a low wall with one knee bent"
]
},
{
"name": "Cozy lounge",
"slug": "cozy_lounge",
"weight": 1.0,
"items": [
"soft knit cardigan with a fitted tee and lounge trousers",
"oversized sweater with leggings and wool socks",
"relaxed sweatshirt with drawstring joggers",
"ribbed lounge set with a long open cardigan",
"simple cotton tee with loose pajama-style trousers"
],
"scenes": [
{
"slug": "sunny_apartment",
"prompt": "sunny apartment corner with bookshelves and a warm rug"
},
{
"slug": "window_seat",
"prompt": "comfortable window seat with soft curtains and afternoon light"
}
],
"poses": [
"curled comfortably on a sofa",
"standing barefoot near a window with a relaxed smile",
"sitting cross-legged with a casual magazine nearby"
]
}
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3352
View File
File diff suppressed because it is too large Load Diff
+1381
View File
File diff suppressed because it is too large Load Diff