Initial ComfyUI prompt builder nodes
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
Executable
+3352
File diff suppressed because it is too large
Load Diff
+1381
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user