Compare commits
232 Commits
main
...
187940b45f
| Author | SHA1 | Date | |
|---|---|---|---|
| 187940b45f | |||
| 718da9a68d | |||
| 030a1255e1 | |||
| 842d3580f5 | |||
| 15b28b422f | |||
| c74798d80f | |||
| 7f6bf0ffd8 | |||
| d546061959 | |||
| 42857e938e | |||
| 221659a58d | |||
| a4b4dae8cf | |||
| e9bd9c45ca | |||
| 4f97057fc4 | |||
| 4de00bcc9d | |||
| b6314a246a | |||
| a539055565 | |||
| 7a60da23f0 | |||
| 9b69cc8505 | |||
| 613fada952 | |||
| 48a2afc951 | |||
| ec6cc7265c | |||
| 1f9544233e | |||
| de6615c024 | |||
| eb1bdbf305 | |||
| 1ca9c95bfe | |||
| 4a3610fbc9 | |||
| 307ffdba3b | |||
| 3c7ccbb711 | |||
| 007386aae3 | |||
| 098721504d | |||
| a50b9272fe | |||
| 9a4324e08e | |||
| 3cd34f650e | |||
| 2b41a82869 | |||
| 658743d876 | |||
| 08627be954 | |||
| c6f0fc34af | |||
| 80e7e6e156 | |||
| 7778a5f31f | |||
| f91953f12b | |||
| cac4fe47cd | |||
| 5ca5f1b858 | |||
| 29ca3ba369 | |||
| 6a65f7d35c | |||
| 867916ee51 | |||
| 8ae689f0e7 | |||
| a94cb9f8f1 | |||
| 727aea6307 | |||
| 8c012ee560 | |||
| 7e0c9ed13b | |||
| 8a1a34ad08 | |||
| 2b9e880b11 | |||
| 9668bd1709 | |||
| c59c9947b2 | |||
| 1950ce7bbf | |||
| f110ee6a89 | |||
| 7c615bdf7b | |||
| 0c62df36de | |||
| ed67c9ba7b | |||
| 4714e23dc8 | |||
| 83d661919f | |||
| 7cd2d48e6a | |||
| 3cb44af410 | |||
| 87f3645115 | |||
| 96ff37a5a0 | |||
| 9cd1f03bfe | |||
| c69274d2ee | |||
| 002c3b79d4 | |||
| ff6195473b | |||
| d0f2670d9c | |||
| bb7df8ad77 | |||
| 607c2b8751 | |||
| 3d0a8cace8 | |||
| e7bc227c6f | |||
| 728d3e559c | |||
| 5ae2f31a20 | |||
| a8d69083cd | |||
| 29e5e65e5f | |||
| ac4c50bf34 | |||
| 91b8842cb2 | |||
| 0a0951e5e5 | |||
| 95dc8939b6 | |||
| d724e4518a | |||
| c3bce91541 | |||
| 81d69c753e | |||
| ec79257613 | |||
| 7bc08ada47 | |||
| c7e4bdc373 | |||
| 0289a94153 | |||
| c34886b362 | |||
| 4fdef3875b | |||
| 333fa5eae6 | |||
| 6f6afb4d22 | |||
| 928f55d2c3 | |||
| bd3adfcd5a | |||
| c4d5477bf9 | |||
| 194eb06465 | |||
| 17c6d34784 | |||
| f811c02641 | |||
| 75a71a2df6 | |||
| 63e8489fb2 | |||
| 616d1132ff | |||
| 58f74e44e5 | |||
| 6ff3b0cbd5 | |||
| 811ff86f72 | |||
| 2dbaaeddb3 | |||
| e5e194c68b | |||
| 2a5e565ce7 | |||
| 8eb3f6d394 | |||
| 3599a334be | |||
| 05c84c6b83 | |||
| 9a2e5db041 | |||
| c67be207ab | |||
| 1ee0b6e91a | |||
| 837299be6c | |||
| 84c369c190 | |||
| 9a5809deaa | |||
| f1567118b4 | |||
| 2605fae3eb | |||
| 8fc3abc504 | |||
| d7caf1c270 | |||
| b38b27acfd | |||
| 0ccb87799b | |||
| 09fc31f078 | |||
| 5ec17df1a4 | |||
| 176d4c9257 | |||
| 8398a97cdf | |||
| 28612f9d00 | |||
| 2c978c7eab | |||
| 00139d0cd9 | |||
| 6abd17b165 | |||
| 2b221463ee | |||
| 09eaafc8f6 | |||
| a5b648eb98 | |||
| 58abbaa347 | |||
| ddf72a87dd | |||
| a7e1a37ad8 | |||
| f7164480df | |||
| d31d513ec3 | |||
| c076b22b75 | |||
| 55fec890a5 | |||
| b46b709e8a | |||
| 3c1f6784c1 | |||
| 23bcb1b526 | |||
| 58ddda82d7 | |||
| 3d9dbdc95d | |||
| e5822e42f8 | |||
| d9275f5f0c | |||
| 70a8698cbe | |||
| e9cc75bd5f | |||
| 3f251a6bb7 | |||
| b3fce97efd | |||
| 20c69b6feb | |||
| 9884b6f6e7 | |||
| 972c8f14b6 | |||
| 049f2c6e87 | |||
| 61535cc60d | |||
| 9ca2320df2 | |||
| 7f808be997 | |||
| d4d3be5789 | |||
| 1cc65e35b5 | |||
| 132d457bf7 | |||
| 0eada863d8 | |||
| ab2a13ecde | |||
| cfe11a4634 | |||
| c0c2fb2b40 | |||
| 7d112c0f98 | |||
| dfdfff953b | |||
| de1d23fb37 | |||
| dc94b1c4c1 | |||
| 2d3d668359 | |||
| 5ab2433ca7 | |||
| 21da2949c6 | |||
| 36ce394462 | |||
| 5efa073bfb | |||
| 64887a2750 | |||
| a128b2dc9a | |||
| 4c45d96472 | |||
| b54b8b9421 | |||
| 2165e9fc16 | |||
| 6a3f88ef59 | |||
| 50d0ffa7e3 | |||
| 5675536009 | |||
| 65574222b2 | |||
| 4c31553409 | |||
| f3f9929df5 | |||
| fef2bf6d81 | |||
| 6abcccbae1 | |||
| f552f76c1a | |||
| bc5ec35ef7 | |||
| 30b5280da1 | |||
| ef8b7f5b89 | |||
| c8c95db835 | |||
| e56e7173ea | |||
| d01de98516 | |||
| efe13beb79 | |||
| 49fe509aa7 | |||
| e6937d96ac | |||
| 029ece173e | |||
| 9b9b0cbb4c | |||
| b7939a4748 | |||
| e1ec8bd823 | |||
| 8bff345cf7 | |||
| 1ad2015308 | |||
| aeea75c485 | |||
| 7a1d1dcac0 | |||
| dcddfe5d61 | |||
| 3ebbb09d63 | |||
| ee62e2215d | |||
| 04ee754f68 | |||
| 86a8f6167a | |||
| 4646f97ee7 | |||
| 0e7cf60fcb | |||
| f27ba23a62 | |||
| 3cbded3f45 | |||
| 2f7c359fab | |||
| 8668dfec9d | |||
| 0e49aed8ac | |||
| f6d6dfffb4 | |||
| c08af2c14a | |||
| ce90fb7593 | |||
| 1c661b3c9d | |||
| 0b9ee3b8b1 | |||
| 6c5a529e29 | |||
| 659a730169 | |||
| 031223255d | |||
| 92469daf03 | |||
| a4a8a7a28e | |||
| b82cf3fbbf | |||
| 97c49fffed | |||
| 1a98fdb9f2 | |||
| 5c5120a1f9 |
+46
-72
@@ -1,81 +1,55 @@
|
||||
# Improvement Path
|
||||
|
||||
## Done
|
||||
This file is a current-state improvement map. It should stay concrete: add work
|
||||
here when it reflects an observed workflow problem, a new metadata path, or a
|
||||
specific generated prompt failure.
|
||||
|
||||
- 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.
|
||||
## Current Baseline
|
||||
|
||||
## Highest-Value Next Steps
|
||||
- Categories, subcategories, item templates, scene pools, expression pools, and
|
||||
composition pools are JSON-driven.
|
||||
- New pool content can be added through extension data instead of Python edits.
|
||||
- Prompt rows and Insta/OF pairs carry structured metadata for Krea2, SDXL, and
|
||||
caption routes.
|
||||
- Krea2, SDXL, and caption formatting prefer metadata over raw prompt text.
|
||||
- Seed behavior is axis-based: global seed, seed control, seed locker,
|
||||
per-character slot seed, and deterministic route simulation are available.
|
||||
- Character slots support chained casts, saved profiles, side-node
|
||||
characteristics, per-character expression, and per-character clothing state.
|
||||
- Insta/OF pairs can generate softcore and hardcore prompts from a shared cast,
|
||||
scene, and camera configuration.
|
||||
- Hardcore position/action routing is split by action family and configurable
|
||||
through position-pool and action-filter nodes.
|
||||
- Camera is first-class: manual camera control, orbit/Qwen camera translation,
|
||||
POV policy, and location-aware scene-camera adapters are separate concerns.
|
||||
- Utility nodes cover index switching, loop control, accumulation, image
|
||||
preview management, persistent text preview, SDXL buckets, and Krea2
|
||||
resolution selection.
|
||||
- `tools/prompt_map_audit.py`, `tools/prompt_route_simulation.py`, and
|
||||
`tools/prompt_smoke.py` cover the main registration, metadata, formatter, and
|
||||
seed-control drift paths.
|
||||
|
||||
1. Explicitness preset
|
||||
## Improvement Rule
|
||||
|
||||
Add a node input like:
|
||||
Do not add broad checks or generic prompt rewrites just because an issue is
|
||||
possible. Improve a path when one of these is true:
|
||||
|
||||
- `softcore`
|
||||
- `nude`
|
||||
- `explicit`
|
||||
- `hardcore`
|
||||
- A generated prompt shows concrete noise, contradiction, or hidden logic drift.
|
||||
- A workflow action is awkward in ComfyUI and needs a better node surface.
|
||||
- A new metadata field, node, category family, formatter route, or location
|
||||
adapter is added.
|
||||
- A formatter starts relying on raw prompt text where structured metadata should
|
||||
exist.
|
||||
|
||||
Then categories can share the same cast/person/scene system while swapping
|
||||
the pose/content pools and negative prompts.
|
||||
## Concrete Next Work
|
||||
|
||||
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.
|
||||
1. Add route-level smoke fixtures only for observed generated edge cases or new
|
||||
metadata fields that affect Krea2, SDXL, or caption output.
|
||||
2. Extend `scene_camera_adapters.py` one location family at a time, after the
|
||||
actual generated prompts show the location needs camera-aware wording.
|
||||
3. Add characteristic side nodes only when repeated manual slot fields become
|
||||
workflow friction.
|
||||
4. Tune hardcore, softcore, SDXL, or caption wording only from real output
|
||||
examples, not from speculative prompt rules.
|
||||
5. When adding a node/path, update the route map and audit coverage in the same
|
||||
change so organization stays discoverable.
|
||||
|
||||
@@ -38,6 +38,23 @@ The node is registered as:
|
||||
- `prompt_builder / SxCP Krea2 Formatter`
|
||||
- `prompt_builder / SxCP Insta/OF Options`
|
||||
- `prompt_builder / SxCP Insta/OF Prompt Pair`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Start`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Cast`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Character`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Wardrobe`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Location`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Set Dressing`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Blocking`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Action`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Performance`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Camera`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Composition`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Lighting`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Branch Pair`
|
||||
- `prompt_builder / v2_scene / SxCP Softcore Branch Options`
|
||||
- `prompt_builder / v2_scene / SxCP Hardcore Branch Options`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Output`
|
||||
- `prompt_builder / v2_scene / SxCP Scene Pair Output`
|
||||
|
||||
It outputs:
|
||||
|
||||
@@ -64,10 +81,14 @@ node. For cleaner workflows, use the split nodes:
|
||||
`men_weights=0.5,0.3` means 50% no man and 30% one man.
|
||||
- `SxCP Location Pool` outputs `location_config`. `replace` uses only the
|
||||
selected/custom location pool; `add` keeps the category's own locations and
|
||||
adds yours. Custom lines can be plain location text, or `slug: location text`.
|
||||
adds yours. Custom lines can be plain location text, `slug: location text`, or
|
||||
one-line JSON objects/arrays. JSON location entries preserve metadata such as
|
||||
inline `camera_profile` / `scene_camera_profile`.
|
||||
- `SxCP Composition Pool` outputs `composition_config` to control framing
|
||||
separately from location. Use it when category framing mentions unrelated
|
||||
outfit-check details such as shoes, bags, or mirror poses.
|
||||
outfit-check details such as shoes, bags, or mirror poses. Custom composition
|
||||
lines can also be one-line JSON objects/arrays when metadata needs to travel
|
||||
with the selected composition.
|
||||
- `SxCP Location Theme` outputs matched `location_config` and
|
||||
`composition_config`. Themes such as `classical_library`,
|
||||
`semi_public_affair`, `hotel_corridor`, `parking_garage`, and
|
||||
@@ -97,6 +118,20 @@ The practical compact workflow is:
|
||||
`Woman Slot` / `Man Slot`, and `Character Profile`
|
||||
into `Prompt Builder From Configs`.
|
||||
|
||||
## Scene-Chain v2 Nodes
|
||||
|
||||
The v2 scene nodes are an additive workflow surface. They pass one structured
|
||||
`SXCP_SCENE` object through cast, character, wardrobe, location, set dressing,
|
||||
blocking, action, performance, camera, composition, and lighting layers. Use
|
||||
`SxCP Scene Output` for a single prompt, or split a shared scene with
|
||||
`SxCP Scene Branch Pair`, refine it with `SxCP Softcore Branch Options` and
|
||||
`SxCP Hardcore Branch Options`, then render both sides through
|
||||
`SxCP Scene Pair Output`.
|
||||
|
||||
The current v2 output nodes intentionally reuse the existing builder,
|
||||
Insta/OF pair, and formatter metadata routes. This keeps old workflows working
|
||||
while giving new workflows a cleaner movie-scene structure.
|
||||
|
||||
An importable default workflow is included at
|
||||
`examples/default_task_lanes_workflow.json`. It is laid out by task instead of
|
||||
as one long chain:
|
||||
@@ -331,11 +366,11 @@ prompt result. Manual fields and explicitly fixed per-axis or character-slot
|
||||
seeds still override the global seed for those parts.
|
||||
|
||||
`SxCP Seed Control` outputs `seed_config`, which can be connected to the prompt
|
||||
builder's optional `seed_config` input. When an axis is set to `random`, the
|
||||
visible seed value is materialized before the workflow queues, and that exact
|
||||
value is used for the queued prompt. The mode returns to `random` after queueing
|
||||
so the next run can reroll. Use `Lock Random Seeds Now` on the node when you want
|
||||
to convert the current random axes into fixed reusable seeds.
|
||||
builder's optional `seed_config` input. It also outputs a `summary` string with
|
||||
the resolved value for every axis. When an axis is set to `random`, the widget
|
||||
can stay at `-1`, but the emitted `seed_config` and `summary` contain the
|
||||
concrete seed used for that queued prompt. Use `Lock Random Seeds Now` on the
|
||||
node when you want to convert the current random axes into fixed reusable seeds.
|
||||
|
||||
`SxCP Seed Locker` is the fast version for iteration. Set `base_seed` to a seed
|
||||
you like, choose one `reroll_axis`, and connect its `seed_config`. All other
|
||||
@@ -413,13 +448,42 @@ The translator accepts the Qwen labels such as `front-right quarter view`,
|
||||
as the native camera nodes. `suppress_phone_visibility` is enabled by default so
|
||||
generic Qwen camera views do not add `phone hidden` or other phone wording.
|
||||
|
||||
For coworking-style locations, the prompt builder also uses the translated
|
||||
camera geometry to add a location-aware framing sentence. It currently targets
|
||||
`coworking lounge`, `business cafe`, and empty office scenes: front/side/back
|
||||
views, zoom, and elevation change which desks, windows, laptop tables, glass
|
||||
partitions, counters, or office rows are kept visible. In male-POV setups this
|
||||
becomes a first-person spatial description and the external camera sentence is
|
||||
suppressed.
|
||||
For camera-aware locations, the prompt builder also uses the translated camera
|
||||
geometry to add a location-aware framing sentence. It currently has scene
|
||||
profiles for coworking/business-office spaces, classical library/book-stack
|
||||
spaces, and semi-public repeating-structure locations such as hotel corridors,
|
||||
parking garages, archives, laundromats, station lockers, backstage halls, wine
|
||||
cellars, nightclub back halls, and restaurant booths. Front/side/back views,
|
||||
zoom, and elevation change which desks, windows, partitions, bookshelves,
|
||||
corridors, pillars, shelves, tables, lamps, or aisles are kept visible. In
|
||||
male-POV setups this becomes a first-person spatial description and the
|
||||
external camera sentence is suppressed.
|
||||
Rows keep the selected `scene_entry`, `location_theme`, `scene_theme`,
|
||||
`composition_entry`, `composition_theme`, and `scene_camera_profile_key` in
|
||||
`metadata_json` so location/camera behavior can be debugged without guessing
|
||||
from prompt text alone.
|
||||
When camera-aware profile routing runs, explicit `scene_camera_profile_key` and
|
||||
theme metadata are used before fallback text matching.
|
||||
Advanced scene entries may also include an inline `camera_profile` /
|
||||
`scene_camera_profile` object with `layout_label`, `foreground`, `midground`,
|
||||
`background`, and optional composition text, so custom location packs can define
|
||||
their own camera behavior.
|
||||
|
||||
`SxCP SDXL Formatter` rewrites prompt builder output or `metadata_json` into
|
||||
comma-tag SDXL/Pony-style prompts. Connect `metadata_json` when possible so
|
||||
character, camera, outfit, and action metadata stay available to the tag route.
|
||||
|
||||
SDXL formatter controls:
|
||||
|
||||
- `formatter_profile`: `manual_controls` keeps `style_preset` and
|
||||
`quality_preset` authoritative. `pony_flat_vector`, `sdxl_photo`, and
|
||||
`flat_vector` apply coherent formatter defaults.
|
||||
- `style_preset`: positive style anchor such as `flat_vector_pony`,
|
||||
`flat_vector`, or `photographic`.
|
||||
- `quality_preset`: quality/score tail such as `pony_high` or `sdxl_high`.
|
||||
- `trigger` and `prepend_trigger_to_prompt`: explicit model/LoRA trigger
|
||||
placement for SDXL-style workflows.
|
||||
- `custom_style` and `custom_quality`: override the selected preset text.
|
||||
|
||||
`SxCP Caption Naturalizer` rewrites tag-like captions or labeled prompts into
|
||||
more natural language. Connect the prompt builder's `metadata_json` output to
|
||||
@@ -429,13 +493,20 @@ cleanup.
|
||||
|
||||
When connected to `SxCP Insta/OF Prompt Pair` metadata, the naturalizer emits a
|
||||
single combined natural caption with the shared descriptor plus separate
|
||||
softcore and hardcore version descriptions. It uses the final selected
|
||||
softcore and hardcore side descriptions. It uses the final selected
|
||||
expression and composition from the generated rows, including any expression
|
||||
pool and intensity settings.
|
||||
Set `target=softcore` or `target=hardcore` to emit only one side of the pair for
|
||||
training captions or formatter chains.
|
||||
|
||||
Naturalizer controls:
|
||||
|
||||
- `input_hint`: `auto`, `metadata_json`, or `caption_or_prompt`.
|
||||
- `target`: `auto` keeps the combined pair caption; `single`, `softcore`, and
|
||||
`hardcore` mirror the formatter target controls.
|
||||
- `caption_profile`: `manual_controls` keeps the detail/style/trigger widgets
|
||||
authoritative; `training_concise`, `training_dense`, and `browsing` apply
|
||||
preset caption behavior.
|
||||
- `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.
|
||||
@@ -847,10 +918,12 @@ axis has its own mode plus seed value:
|
||||
- `follow_main`: always follows the final generator's main `seed` input and
|
||||
ignores the entered axis seed.
|
||||
- `fixed`: always uses the entered axis seed.
|
||||
- `random`: generates a fresh visible axis seed when the workflow queues.
|
||||
- `random`: generates a fresh resolved axis seed when the workflow queues.
|
||||
|
||||
The `Lock Random Seeds Now` button turns every current `random` axis into a
|
||||
visible concrete seed and switches those axes to `fixed`.
|
||||
The `summary` output lists the resolved value for every axis, including random
|
||||
axes whose visible widget value remains `-1`. The `Lock Random Seeds Now` button
|
||||
turns every current `random` axis into a visible concrete seed and switches
|
||||
those axes to `fixed`.
|
||||
|
||||
For exact prompt reproduction, `SxCP Global Seed` is the shortest path:
|
||||
|
||||
|
||||
+124
-3156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptFromConfigsRequest:
|
||||
row_number: int
|
||||
start_index: int
|
||||
seed: int
|
||||
category_config: str | dict[str, Any] | None = ""
|
||||
cast_config: str | dict[str, Any] | None = ""
|
||||
generation_profile: str | dict[str, Any] | None = ""
|
||||
filter_config: str | dict[str, Any] | None = ""
|
||||
seed_config: str | dict[str, Any] | None = ""
|
||||
camera_config: str | dict[str, Any] | None = ""
|
||||
character_profile: str | dict[str, Any] | None = ""
|
||||
character_cast: str | dict[str, Any] | list[Any] | None = ""
|
||||
hardcore_position_config: str | dict[str, Any] | None = ""
|
||||
location_config: str | dict[str, Any] | None = ""
|
||||
composition_config: str | dict[str, Any] | None = ""
|
||||
extra_positive: str = ""
|
||||
extra_negative: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptFromConfigsRoute:
|
||||
row: dict[str, Any]
|
||||
category: str
|
||||
subcategory: str
|
||||
cast: dict[str, Any]
|
||||
profile: dict[str, Any]
|
||||
filters: dict[str, Any]
|
||||
build_kwargs: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptFromConfigsDependencies:
|
||||
parse_category_config: Callable[[str | dict[str, Any] | None], tuple[str, str]]
|
||||
parse_cast_config: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
parse_generation_profile: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
parse_filter_config: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
build_prompt: Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def build_prompt_from_configs_result(
|
||||
request: PromptFromConfigsRequest,
|
||||
deps: PromptFromConfigsDependencies,
|
||||
) -> PromptFromConfigsRoute:
|
||||
category, subcategory = deps.parse_category_config(request.category_config)
|
||||
cast = deps.parse_cast_config(request.cast_config)
|
||||
profile = deps.parse_generation_profile(request.generation_profile)
|
||||
filters = deps.parse_filter_config(request.filter_config)
|
||||
build_kwargs: dict[str, Any] = {
|
||||
"category": category,
|
||||
"subcategory": subcategory,
|
||||
"row_number": request.row_number,
|
||||
"start_index": request.start_index,
|
||||
"seed": request.seed,
|
||||
"clothing": profile["clothing"],
|
||||
"ethnicity": filters["ethnicity"],
|
||||
"poses": profile["poses"],
|
||||
"expression_enabled": profile["expression_enabled"],
|
||||
"expression_intensity": profile["expression_intensity"],
|
||||
"backside_bias": profile["backside_bias"],
|
||||
"figure": filters["figure"],
|
||||
"no_plus_women": filters["no_plus_women"],
|
||||
"no_black": filters["no_black"],
|
||||
"women_count": int(cast["women_count"]),
|
||||
"men_count": int(cast["men_count"]),
|
||||
"minimal_clothing_ratio": profile["minimal_clothing_ratio"],
|
||||
"standard_pose_ratio": profile["standard_pose_ratio"],
|
||||
"trigger": profile["trigger"],
|
||||
"prepend_trigger_to_prompt": profile["prepend_trigger_to_prompt"],
|
||||
"extra_positive": request.extra_positive or "",
|
||||
"extra_negative": request.extra_negative or "",
|
||||
"seed_config": request.seed_config or "",
|
||||
"camera_config": request.camera_config or "",
|
||||
"character_profile": request.character_profile or "",
|
||||
"character_cast": request.character_cast or "",
|
||||
"hardcore_position_config": request.hardcore_position_config or "",
|
||||
"location_config": request.location_config or "",
|
||||
"composition_config": request.composition_config or "",
|
||||
}
|
||||
return PromptFromConfigsRoute(
|
||||
row=deps.build_prompt(**build_kwargs),
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
cast=dict(cast),
|
||||
profile=dict(profile),
|
||||
filters=dict(filters),
|
||||
build_kwargs=build_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def build_prompt_from_configs(
|
||||
request: PromptFromConfigsRequest,
|
||||
deps: PromptFromConfigsDependencies,
|
||||
) -> dict[str, Any]:
|
||||
return build_prompt_from_configs_result(request, deps).row
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import seed_config as seed_policy
|
||||
except ImportError: # pragma: no cover - plain-script smoke tests
|
||||
import seed_config as seed_policy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptBuildRequest:
|
||||
category: str
|
||||
subcategory: str
|
||||
row_number: int
|
||||
start_index: int
|
||||
seed: int
|
||||
clothing: str
|
||||
ethnicity: str
|
||||
poses: str
|
||||
backside_bias: float
|
||||
figure: str
|
||||
no_plus_women: bool
|
||||
no_black: bool
|
||||
minimal_clothing_ratio: float
|
||||
standard_pose_ratio: float
|
||||
trigger: str
|
||||
prepend_trigger_to_prompt: bool
|
||||
extra_positive: str
|
||||
extra_negative: str
|
||||
seed_config: str | dict[str, Any] | None = None
|
||||
women_count: int = 1
|
||||
men_count: int = 1
|
||||
camera_config: str | dict[str, Any] | None = None
|
||||
expression_intensity: float = 0.5
|
||||
character_profile: str | dict[str, Any] | None = None
|
||||
character_cast: str | dict[str, Any] | list[Any] | None = None
|
||||
expression_enabled: bool = True
|
||||
expression_phase: str = ""
|
||||
hardcore_position_config: str | dict[str, Any] | None = None
|
||||
location_config: str | dict[str, Any] | None = None
|
||||
composition_config: str | dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptBuildRoute:
|
||||
row: dict[str, Any]
|
||||
category: str
|
||||
subcategory: str
|
||||
branch: str
|
||||
parsed_seed_config: dict[str, Any]
|
||||
expression_intensity: float
|
||||
expression_intensity_source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptBuildDependencies:
|
||||
default_trigger: str
|
||||
default_negative: str
|
||||
random_subcategory: str
|
||||
apply_pool_extensions: Callable[[], Any]
|
||||
normalize_ethnicity_filter: Callable[[Any, str], str]
|
||||
is_false: Callable[[Any], bool]
|
||||
ratio_or_none: Callable[[Any], float | None]
|
||||
parse_seed_config: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
parse_location_config: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
parse_composition_config: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
axis_rng: Callable[[dict[str, Any], str, int, int], Any]
|
||||
pick_clothing_mode: Callable[[Any, str, float | None], str]
|
||||
pick_pose_mode: Callable[[Any, str, float | None], str]
|
||||
pick_figure_bias: Callable[[Any, str], str]
|
||||
pick_expression_intensity: Callable[[Any, Any], tuple[float, str]]
|
||||
auto_full_choice: Callable[[dict[str, Any], int, int], str]
|
||||
build_auto_weighted_row: Callable[..., dict[str, Any]]
|
||||
build_direct_builtin_row: Callable[..., dict[str, Any]]
|
||||
build_custom_row: Callable[..., dict[str, Any]]
|
||||
apply_location_config_to_legacy_row: Callable[..., dict[str, Any]]
|
||||
apply_composition_config_to_legacy_row: Callable[..., dict[str, Any]]
|
||||
disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]]
|
||||
apply_camera_config: Callable[[dict[str, Any], str | dict[str, Any] | None], dict[str, Any]]
|
||||
normalize_prompt_row: Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def _generation_trace(
|
||||
*,
|
||||
row: dict[str, Any],
|
||||
request: PromptBuildRequest,
|
||||
row_number: int,
|
||||
start_index: int,
|
||||
seed: int,
|
||||
category: str,
|
||||
subcategory: str,
|
||||
branch: str,
|
||||
parsed_seed_config: dict[str, Any],
|
||||
clothing: str,
|
||||
poses: str,
|
||||
figure: str,
|
||||
expression_enabled: bool,
|
||||
expression_intensity: float,
|
||||
expression_intensity_source: str,
|
||||
exact_custom_subcategory: bool,
|
||||
) -> dict[str, Any]:
|
||||
trace = {
|
||||
"builder": "prompt_builder",
|
||||
"branch": branch,
|
||||
"source": row.get("source", ""),
|
||||
"category_input": request.category,
|
||||
"subcategory_input": request.subcategory,
|
||||
"category": category,
|
||||
"subcategory": row.get("subcategory") or subcategory,
|
||||
"category_slug": row.get("category_slug", ""),
|
||||
"subcategory_slug": row.get("subcategory_slug", ""),
|
||||
"exact_custom_subcategory": bool(exact_custom_subcategory),
|
||||
"row_number": row_number,
|
||||
"start_index": start_index,
|
||||
"seed": seed,
|
||||
"seed_axes": seed_policy.axis_seed_trace(parsed_seed_config, seed, row_number),
|
||||
"content_seed_axis": row.get("content_seed_axis") or ("pose" if row.get("position_family") else "content"),
|
||||
"clothing": clothing,
|
||||
"poses": poses,
|
||||
"figure": figure,
|
||||
"expression_enabled": bool(expression_enabled),
|
||||
"expression_intensity": expression_intensity,
|
||||
"expression_intensity_source": expression_intensity_source,
|
||||
"trigger": row.get("trigger", ""),
|
||||
}
|
||||
if row.get("cast_count_adjustment"):
|
||||
trace["cast_count_adjustment"] = row.get("cast_count_adjustment")
|
||||
return trace
|
||||
|
||||
|
||||
def build_prompt_result(request: PromptBuildRequest, deps: PromptBuildDependencies) -> PromptBuildRoute:
|
||||
deps.apply_pool_extensions()
|
||||
row_number = max(1, int(request.row_number))
|
||||
start_index = max(1, int(request.start_index))
|
||||
seed = int(request.seed)
|
||||
category = request.category
|
||||
subcategory = request.subcategory
|
||||
ethnicity = deps.normalize_ethnicity_filter(request.ethnicity, "any")
|
||||
expression_enabled = not deps.is_false(request.expression_enabled)
|
||||
minimal_ratio = deps.ratio_or_none(request.minimal_clothing_ratio)
|
||||
pose_ratio = deps.ratio_or_none(request.standard_pose_ratio)
|
||||
parsed_seed_config = deps.parse_seed_config(request.seed_config)
|
||||
parsed_location_config = deps.parse_location_config(request.location_config)
|
||||
parsed_composition_config = deps.parse_composition_config(request.composition_config)
|
||||
content_rng = deps.axis_rng(parsed_seed_config, "content", seed, row_number)
|
||||
pose_axis_rng = deps.axis_rng(parsed_seed_config, "pose", seed, row_number)
|
||||
person_rng = deps.axis_rng(parsed_seed_config, "person", seed, row_number)
|
||||
expression_rng = deps.axis_rng(parsed_seed_config, "expression", seed, row_number)
|
||||
clothing = request.clothing if request.clothing in ("full", "minimal", "random") else "full"
|
||||
poses = request.poses if request.poses in ("standard", "evocative", "random") else "standard"
|
||||
figure = request.figure if request.figure in ("curvy", "balanced", "bombshell", "random") else "curvy"
|
||||
clothing = deps.pick_clothing_mode(content_rng, clothing, minimal_ratio)
|
||||
poses = deps.pick_pose_mode(pose_axis_rng, poses, pose_ratio)
|
||||
figure = deps.pick_figure_bias(person_rng, figure)
|
||||
minimal_ratio = None
|
||||
pose_ratio = None
|
||||
expression_intensity, expression_intensity_source = deps.pick_expression_intensity(
|
||||
expression_rng,
|
||||
request.expression_intensity,
|
||||
)
|
||||
|
||||
exact_custom_subcategory = bool(
|
||||
subcategory and subcategory != deps.random_subcategory and " / " in subcategory
|
||||
)
|
||||
|
||||
if category == "auto_full" and not exact_custom_subcategory:
|
||||
category = deps.auto_full_choice(parsed_seed_config, seed, row_number)
|
||||
|
||||
branch = "custom"
|
||||
if category == "auto_weighted" and not exact_custom_subcategory:
|
||||
branch = "auto_weighted"
|
||||
row = deps.build_auto_weighted_row(
|
||||
row_number,
|
||||
start_index,
|
||||
clothing,
|
||||
ethnicity,
|
||||
poses,
|
||||
float(request.backside_bias),
|
||||
figure,
|
||||
bool(request.no_plus_women),
|
||||
bool(request.no_black),
|
||||
minimal_ratio,
|
||||
pose_ratio,
|
||||
seed,
|
||||
)
|
||||
elif category in ("woman", "man", "couple", "group_or_layout") and not exact_custom_subcategory:
|
||||
branch = "built_in"
|
||||
row = deps.build_direct_builtin_row(
|
||||
category,
|
||||
row_number,
|
||||
start_index,
|
||||
clothing,
|
||||
ethnicity,
|
||||
poses,
|
||||
float(request.backside_bias),
|
||||
figure,
|
||||
bool(request.no_plus_women),
|
||||
bool(request.no_black),
|
||||
minimal_ratio,
|
||||
pose_ratio,
|
||||
seed,
|
||||
)
|
||||
else:
|
||||
row = deps.build_custom_row(
|
||||
category,
|
||||
subcategory,
|
||||
row_number,
|
||||
start_index,
|
||||
ethnicity,
|
||||
poses,
|
||||
figure,
|
||||
bool(request.no_plus_women),
|
||||
bool(request.no_black),
|
||||
int(request.women_count),
|
||||
int(request.men_count),
|
||||
seed,
|
||||
parsed_seed_config,
|
||||
expression_enabled,
|
||||
expression_intensity,
|
||||
expression_intensity_source,
|
||||
request.character_profile,
|
||||
request.character_cast,
|
||||
request.expression_phase,
|
||||
request.hardcore_position_config,
|
||||
parsed_location_config,
|
||||
parsed_composition_config,
|
||||
)
|
||||
|
||||
if row.get("source") == "built_in_generator":
|
||||
row = deps.apply_location_config_to_legacy_row(
|
||||
row,
|
||||
parsed_location_config,
|
||||
parsed_seed_config,
|
||||
seed,
|
||||
row_number,
|
||||
)
|
||||
row = deps.apply_composition_config_to_legacy_row(
|
||||
row,
|
||||
parsed_composition_config,
|
||||
parsed_seed_config,
|
||||
seed,
|
||||
row_number,
|
||||
)
|
||||
if not expression_enabled:
|
||||
row = deps.disable_row_expression(row, "disabled")
|
||||
row = deps.apply_camera_config(row, request.camera_config)
|
||||
active_trigger = request.trigger.strip() or deps.default_trigger
|
||||
row = deps.normalize_prompt_row(
|
||||
row,
|
||||
active_trigger=active_trigger,
|
||||
prepend_trigger_to_prompt=bool(request.prepend_trigger_to_prompt),
|
||||
extra_positive=request.extra_positive,
|
||||
extra_negative=request.extra_negative,
|
||||
default_negative=deps.default_negative,
|
||||
)
|
||||
row.setdefault("expression_intensity", expression_intensity)
|
||||
row.setdefault("expression_intensity_source", expression_intensity_source)
|
||||
row["generation_trace"] = _generation_trace(
|
||||
row=row,
|
||||
request=request,
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
branch=branch,
|
||||
parsed_seed_config=parsed_seed_config,
|
||||
clothing=clothing,
|
||||
poses=poses,
|
||||
figure=figure,
|
||||
expression_enabled=expression_enabled,
|
||||
expression_intensity=expression_intensity,
|
||||
expression_intensity_source=expression_intensity_source,
|
||||
exact_custom_subcategory=exact_custom_subcategory,
|
||||
)
|
||||
return PromptBuildRoute(
|
||||
row=row,
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
branch=branch,
|
||||
parsed_seed_config=dict(parsed_seed_config),
|
||||
expression_intensity=expression_intensity,
|
||||
expression_intensity_source=expression_intensity_source,
|
||||
)
|
||||
|
||||
|
||||
def build_prompt(request: PromptBuildRequest, deps: PromptBuildDependencies) -> dict[str, Any]:
|
||||
return build_prompt_result(request, deps).row
|
||||
@@ -0,0 +1,633 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
|
||||
CAMERA_ORBIT_FRAMING_CHOICES = [
|
||||
"from_zoom",
|
||||
"wide",
|
||||
"medium",
|
||||
"full_body",
|
||||
"three_quarter",
|
||||
"close_up",
|
||||
"extreme_close_up",
|
||||
]
|
||||
CAMERA_ORBIT_FOCUS_CHOICES = [
|
||||
"auto",
|
||||
"face",
|
||||
"torso",
|
||||
"hips",
|
||||
"full_body",
|
||||
"action",
|
||||
"contact_points",
|
||||
"environment",
|
||||
]
|
||||
|
||||
CAMERA_MODE_PROMPTS = {
|
||||
"disabled": "",
|
||||
"standard": "",
|
||||
"handheld_selfie": (
|
||||
"Camera mode: handheld smartphone selfie, close arm-length framing, visible creator-shot perspective, "
|
||||
"slight wide-angle intimacy, direct eye contact, natural phone-camera composition."
|
||||
),
|
||||
"mirror_selfie": (
|
||||
"Camera mode: mirror selfie with the phone visible in one hand, reflective framing, creator looking at the screen, "
|
||||
"body and environment visible through the mirror."
|
||||
),
|
||||
"phone_tripod": (
|
||||
"Camera mode: phone on tripod or ring-light stand, creator-facing social-video framing, stable vertical composition, "
|
||||
"hands-free self-recorded setup."
|
||||
),
|
||||
"creator_pov": (
|
||||
"Camera mode: creator-held POV, intimate subscriber-view angle, the creator controls the camera, close foreground body framing."
|
||||
),
|
||||
"bed_selfie": (
|
||||
"Camera mode: bed selfie shot from a phone held above or beside the body, intimate close framing, sheets visible around the subject."
|
||||
),
|
||||
"bathroom_mirror": (
|
||||
"Camera mode: bathroom mirror selfie, phone visible, tiled private room, close vertical framing, candid creator-shot energy."
|
||||
),
|
||||
"phone_flash": (
|
||||
"Camera mode: direct phone-flash selfie, crisp flash highlights, candid night-post feeling, hard-edged smartphone shadows."
|
||||
),
|
||||
"action_cam": (
|
||||
"Camera mode: body-mounted or handheld action-camera intimacy, very close wide-angle perspective, dynamic creator-shot framing."
|
||||
),
|
||||
}
|
||||
|
||||
CAMERA_COMPACT_LABELS = {
|
||||
"disabled": "",
|
||||
"standard": "",
|
||||
"handheld_selfie": "handheld smartphone selfie",
|
||||
"mirror_selfie": "mirror selfie",
|
||||
"phone_tripod": "phone tripod / ring-light setup",
|
||||
"creator_pov": "creator-held POV",
|
||||
"bed_selfie": "bed selfie",
|
||||
"bathroom_mirror": "bathroom mirror selfie",
|
||||
"phone_flash": "phone-flash selfie",
|
||||
"action_cam": "handheld action-camera view",
|
||||
"full_body": "full body",
|
||||
"three_quarter": "three-quarter body",
|
||||
"waist_up": "waist-up",
|
||||
"close_up": "close-up",
|
||||
"extreme_close_up": "extreme close-up",
|
||||
"eye_level": "eye-level",
|
||||
"high_angle": "high-angle",
|
||||
"low_angle": "low-angle",
|
||||
"overhead": "overhead",
|
||||
"side_profile": "side-profile",
|
||||
"rear_view": "rear-view",
|
||||
"mirror_reflection": "mirror reflection",
|
||||
"smartphone_wide": "smartphone wide-angle",
|
||||
"ultra_wide": "ultra-wide",
|
||||
"portrait_lens": "phone portrait lens",
|
||||
"telephoto": "telephoto-style",
|
||||
"macro_detail": "macro detail",
|
||||
"arm_length": "arm-length",
|
||||
"near_body": "near-body",
|
||||
"bedside": "bedside phone",
|
||||
"room_corner": "room-corner phone",
|
||||
"vertical_story": "vertical 9:16",
|
||||
"square_feed": "square feed",
|
||||
"horizontal": "horizontal",
|
||||
"phone_visible": "phone visible",
|
||||
"phone_hidden": "phone hidden",
|
||||
"screen_reflection": "screen reflection",
|
||||
"ring_light_visible": "ring light visible",
|
||||
}
|
||||
|
||||
CAMERA_SHOT_PROMPTS = {
|
||||
"auto": "",
|
||||
"full_body": "Shot size: full body visible, head-to-toe framing, no important body parts cropped out.",
|
||||
"three_quarter": "Shot size: three-quarter body framing, face, torso, hips, and thighs clearly visible.",
|
||||
"waist_up": "Shot size: waist-up creator framing with face and upper body as the focus.",
|
||||
"close_up": "Shot size: close-up framing with face, expression, hands, and body contact emphasized.",
|
||||
"extreme_close_up": "Shot size: extreme close-up detail shot, tightly framed and intimate.",
|
||||
}
|
||||
|
||||
CAMERA_ANGLE_PROMPTS = {
|
||||
"auto": "",
|
||||
"eye_level": "Angle: eye-level camera angle with direct creator eye contact.",
|
||||
"high_angle": "Angle: high-angle selfie looking down toward the body.",
|
||||
"low_angle": "Angle: low-angle phone camera looking upward from near the body.",
|
||||
"overhead": "Angle: overhead phone shot looking down at the full pose.",
|
||||
"side_profile": "Angle: side-profile camera view emphasizing body silhouette and contact points.",
|
||||
"rear_view": "Angle: rear-view camera framing with the body turned away from the lens.",
|
||||
"mirror_reflection": "Angle: mirror-reflection composition with the phone and reflected body placement readable.",
|
||||
}
|
||||
|
||||
CAMERA_LENS_PROMPTS = {
|
||||
"auto": "",
|
||||
"smartphone_wide": "Lens: smartphone wide-angle lens with slight edge distortion and close personal scale.",
|
||||
"ultra_wide": "Lens: ultra-wide phone lens, exaggerated near-camera perspective, environmental context visible.",
|
||||
"portrait_lens": "Lens: phone portrait mode, shallow depth of field, crisp subject separation.",
|
||||
"telephoto": "Lens: compressed telephoto-style framing, flatter proportions, less distortion.",
|
||||
"macro_detail": "Lens: macro-detail phone shot focused on texture, skin, fabric, and contact detail.",
|
||||
}
|
||||
|
||||
CAMERA_DISTANCE_PROMPTS = {
|
||||
"auto": "",
|
||||
"arm_length": "Camera distance: arm-length selfie distance, close enough to feel handheld.",
|
||||
"near_body": "Camera distance: near-body camera placement with intimate foreground framing.",
|
||||
"bedside": "Camera distance: phone placed beside the body on the bed or floor.",
|
||||
"room_corner": "Camera distance: phone set across the room, self-recorded but wider and more observational.",
|
||||
}
|
||||
|
||||
CAMERA_ORIENTATION_PROMPTS = {
|
||||
"auto": "",
|
||||
"vertical_story": "Orientation: vertical 9:16 story/reel framing.",
|
||||
"square_feed": "Orientation: square social-feed crop.",
|
||||
"horizontal": "Orientation: horizontal phone-video crop.",
|
||||
}
|
||||
|
||||
CAMERA_PHONE_PROMPTS = {
|
||||
"auto": "",
|
||||
"phone_visible": "Phone visibility: phone visible in hand or mirror, clearly creator-shot.",
|
||||
"phone_hidden": "Phone visibility: phone is implied but not visible, preserving the selfie/creator-shot perspective.",
|
||||
"screen_reflection": "Phone visibility: screen glow or reflection visible in the scene.",
|
||||
"ring_light_visible": "Phone visibility: ring light or tripod visible enough to read as self-recorded content.",
|
||||
}
|
||||
|
||||
CAMERA_PRIORITY_PROMPTS = {
|
||||
"soft_hint": "Camera priority: treat the camera notes as style guidance.",
|
||||
"strong": "Camera priority: strongly preserve the selected camera, lens, angle, crop, and phone-shot perspective.",
|
||||
"locked": "Camera priority: locked camera constraint; do not replace this with a studio, third-person, cinematic, or unrelated camera view.",
|
||||
}
|
||||
|
||||
QWEN_CAMERA_DIRECTIONS = {
|
||||
"front-right quarter view": 45,
|
||||
"right side view": 90,
|
||||
"back-right quarter view": 135,
|
||||
"back view": 180,
|
||||
"back-left quarter view": 225,
|
||||
"left side view": 270,
|
||||
"front-left quarter view": 315,
|
||||
"front view": 0,
|
||||
}
|
||||
QWEN_CAMERA_ELEVATIONS = {
|
||||
"low-angle shot": -30,
|
||||
"eye-level shot": 0,
|
||||
"elevated shot": 30,
|
||||
"high-angle shot": 60,
|
||||
}
|
||||
QWEN_CAMERA_ZOOMS = {
|
||||
"wide shot": 0.0,
|
||||
"medium shot": 5.0,
|
||||
"close-up": 8.0,
|
||||
}
|
||||
QWEN_CAMERA_SCENE_CENTER_Y = 0.5
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def _choice(value: Any, choices: dict[str, str], default: str) -> str:
|
||||
value = str(value or default)
|
||||
return value if value in choices else default
|
||||
|
||||
|
||||
def _clean_prompt_punctuation(text: str) -> str:
|
||||
text = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
text = re.sub(r"\s+([,.;:])", r"\1", text)
|
||||
text = re.sub(r"(?:,\s*){2,}", ", ", text)
|
||||
text = re.sub(r"\.\s*\.", ".", text)
|
||||
text = re.sub(r":\s*\.", ".", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def camera_mode_choices() -> list[str]:
|
||||
return list(CAMERA_MODE_PROMPTS)
|
||||
|
||||
|
||||
def camera_detail_choices() -> list[str]:
|
||||
return list(CAMERA_DETAIL_CHOICES)
|
||||
|
||||
|
||||
def camera_orbit_framing_choices() -> list[str]:
|
||||
return list(CAMERA_ORBIT_FRAMING_CHOICES)
|
||||
|
||||
|
||||
def camera_orbit_focus_choices() -> list[str]:
|
||||
return list(CAMERA_ORBIT_FOCUS_CHOICES)
|
||||
|
||||
|
||||
def camera_shot_choices() -> list[str]:
|
||||
return list(CAMERA_SHOT_PROMPTS)
|
||||
|
||||
|
||||
def camera_angle_choices() -> list[str]:
|
||||
return list(CAMERA_ANGLE_PROMPTS)
|
||||
|
||||
|
||||
def camera_lens_choices() -> list[str]:
|
||||
return list(CAMERA_LENS_PROMPTS)
|
||||
|
||||
|
||||
def camera_distance_choices() -> list[str]:
|
||||
return list(CAMERA_DISTANCE_PROMPTS)
|
||||
|
||||
|
||||
def camera_orientation_choices() -> list[str]:
|
||||
return list(CAMERA_ORIENTATION_PROMPTS)
|
||||
|
||||
|
||||
def camera_phone_choices() -> list[str]:
|
||||
return list(CAMERA_PHONE_PROMPTS)
|
||||
|
||||
|
||||
def camera_priority_choices() -> list[str]:
|
||||
return list(CAMERA_PRIORITY_PROMPTS)
|
||||
|
||||
|
||||
def build_camera_config_json(
|
||||
camera_mode: str = "standard",
|
||||
shot_size: str = "auto",
|
||||
angle: str = "auto",
|
||||
lens: str = "auto",
|
||||
distance: str = "auto",
|
||||
orientation: str = "auto",
|
||||
phone_visibility: str = "auto",
|
||||
priority: str = "strong",
|
||||
camera_detail: str = "compact",
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"camera_mode": camera_mode,
|
||||
"shot_size": shot_size,
|
||||
"angle": angle,
|
||||
"lens": lens,
|
||||
"distance": distance,
|
||||
"orientation": orientation,
|
||||
"phone_visibility": phone_visibility,
|
||||
"priority": priority,
|
||||
"camera_detail": camera_detail,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def _camera_orbit_direction(horizontal_angle: Any) -> str:
|
||||
h_angle = int(float(horizontal_angle or 0)) % 360
|
||||
if h_angle < 22.5 or h_angle >= 337.5:
|
||||
return "front view"
|
||||
if h_angle < 67.5:
|
||||
return "front-right quarter view"
|
||||
if h_angle < 112.5:
|
||||
return "right side view"
|
||||
if h_angle < 157.5:
|
||||
return "back-right quarter view"
|
||||
if h_angle < 202.5:
|
||||
return "back view"
|
||||
if h_angle < 247.5:
|
||||
return "back-left quarter view"
|
||||
if h_angle < 292.5:
|
||||
return "left side view"
|
||||
return "front-left quarter view"
|
||||
|
||||
|
||||
def _camera_orbit_elevation(vertical_angle: Any) -> str:
|
||||
vertical = int(float(vertical_angle or 0))
|
||||
if vertical < -15:
|
||||
return "low-angle shot"
|
||||
if vertical < 15:
|
||||
return "eye-level shot"
|
||||
if vertical < 45:
|
||||
return "elevated shot"
|
||||
return "high-angle shot"
|
||||
|
||||
|
||||
def _camera_orbit_distance(zoom: Any, framing: str = "from_zoom") -> str:
|
||||
framing = framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom"
|
||||
framing_labels = {
|
||||
"wide": "wide shot",
|
||||
"medium": "medium shot",
|
||||
"full_body": "full-body shot",
|
||||
"three_quarter": "three-quarter body shot",
|
||||
"close_up": "close-up",
|
||||
"extreme_close_up": "extreme close-up",
|
||||
}
|
||||
if framing != "from_zoom":
|
||||
return framing_labels[framing]
|
||||
zoom_value = float(zoom or 0.0)
|
||||
if zoom_value < 2:
|
||||
return "wide shot"
|
||||
if zoom_value < 6:
|
||||
return "medium shot"
|
||||
return "close-up"
|
||||
|
||||
|
||||
def _camera_orbit_focus(subject_focus: str) -> str:
|
||||
return {
|
||||
"face": "face and expression centered",
|
||||
"torso": "torso and hands centered",
|
||||
"hips": "hips and lower body centered",
|
||||
"full_body": "full body centered",
|
||||
"action": "main action centered",
|
||||
"contact_points": "body contact points centered",
|
||||
"environment": "subject and room both readable",
|
||||
}.get(str(subject_focus or "auto"), "")
|
||||
|
||||
|
||||
def camera_orbit_prompt(
|
||||
horizontal_angle: Any,
|
||||
vertical_angle: Any,
|
||||
zoom: Any,
|
||||
framing: str = "from_zoom",
|
||||
subject_focus: str = "auto",
|
||||
include_degrees: bool = True,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
azimuth = max(0, min(359, int(float(horizontal_angle or 0))))
|
||||
elevation = max(-90, min(90, int(float(vertical_angle or 0))))
|
||||
zoom_value = max(0.0, min(10.0, float(zoom or 0.0)))
|
||||
direction = _camera_orbit_direction(azimuth)
|
||||
elevation_label = _camera_orbit_elevation(elevation)
|
||||
distance_label = _camera_orbit_distance(zoom_value, framing)
|
||||
focus_label = _camera_orbit_focus(subject_focus)
|
||||
pieces = [direction, elevation_label, distance_label, focus_label]
|
||||
prompt = ", ".join(piece for piece in pieces if piece)
|
||||
if include_degrees:
|
||||
prompt = f"{azimuth}-degree {prompt}"
|
||||
return prompt, {
|
||||
"orbit_azimuth": azimuth,
|
||||
"orbit_elevation": elevation,
|
||||
"orbit_zoom": zoom_value,
|
||||
"orbit_direction": direction,
|
||||
"orbit_elevation_label": elevation_label,
|
||||
"orbit_distance_label": distance_label,
|
||||
"orbit_framing": framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom",
|
||||
"orbit_focus": subject_focus if subject_focus in CAMERA_ORBIT_FOCUS_CHOICES else "auto",
|
||||
}
|
||||
|
||||
|
||||
def build_camera_orbit_config_json(
|
||||
enabled: bool = True,
|
||||
camera_mode: str = "standard",
|
||||
horizontal_angle: int = 0,
|
||||
vertical_angle: int = 0,
|
||||
zoom: float = 5.0,
|
||||
framing: str = "from_zoom",
|
||||
subject_focus: str = "auto",
|
||||
lens: str = "auto",
|
||||
orientation: str = "auto",
|
||||
phone_visibility: str = "auto",
|
||||
priority: str = "locked",
|
||||
camera_detail: str = "compact",
|
||||
include_degrees: bool = True,
|
||||
) -> str:
|
||||
orbit_prompt, orbit_metadata = camera_orbit_prompt(
|
||||
horizontal_angle,
|
||||
vertical_angle,
|
||||
zoom,
|
||||
framing=framing,
|
||||
subject_focus=subject_focus,
|
||||
include_degrees=include_degrees,
|
||||
)
|
||||
config = {
|
||||
"camera_mode": "disabled" if _is_false(enabled) else _choice(camera_mode, CAMERA_MODE_PROMPTS, "standard"),
|
||||
"shot_size": "auto",
|
||||
"angle": "auto",
|
||||
"lens": _choice(lens, CAMERA_LENS_PROMPTS, "auto"),
|
||||
"distance": "auto",
|
||||
"orientation": _choice(orientation, CAMERA_ORIENTATION_PROMPTS, "auto"),
|
||||
"phone_visibility": _choice(phone_visibility, CAMERA_PHONE_PROMPTS, "auto"),
|
||||
"priority": _choice(priority, CAMERA_PRIORITY_PROMPTS, "locked"),
|
||||
"camera_detail": camera_detail if camera_detail in CAMERA_DETAIL_CHOICES else "compact",
|
||||
"camera_source": "orbit",
|
||||
"custom_camera_prompt": orbit_prompt if not _is_false(enabled) else "",
|
||||
**orbit_metadata,
|
||||
}
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def _qwen_prompt_camera_values(qwen_prompt: Any) -> tuple[int, int, float]:
|
||||
text = _clean_prompt_punctuation(str(qwen_prompt or "").lower().replace(",", " "))
|
||||
horizontal_angle = 0
|
||||
vertical_angle = 0
|
||||
zoom = 5.0
|
||||
for label, value in QWEN_CAMERA_DIRECTIONS.items():
|
||||
if label in text:
|
||||
horizontal_angle = value
|
||||
break
|
||||
for label, value in QWEN_CAMERA_ELEVATIONS.items():
|
||||
if label in text:
|
||||
vertical_angle = value
|
||||
break
|
||||
for label, value in QWEN_CAMERA_ZOOMS.items():
|
||||
if label in text:
|
||||
zoom = value
|
||||
break
|
||||
return horizontal_angle, vertical_angle, zoom
|
||||
|
||||
|
||||
def _camera_info_dict(camera_info: Any) -> dict[str, Any] | None:
|
||||
if not camera_info:
|
||||
return None
|
||||
if isinstance(camera_info, dict):
|
||||
return camera_info
|
||||
if isinstance(camera_info, str):
|
||||
try:
|
||||
raw = json.loads(camera_info)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return raw if isinstance(raw, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _qwen_camera_info_values(camera_info: Any) -> tuple[int, int, float] | None:
|
||||
info = _camera_info_dict(camera_info)
|
||||
if not info:
|
||||
return None
|
||||
position = info.get("position") if isinstance(info.get("position"), dict) else {}
|
||||
target = info.get("target") if isinstance(info.get("target"), dict) else {}
|
||||
try:
|
||||
dx = float(position.get("x", 0.0)) - float(target.get("x", 0.0))
|
||||
dy = float(position.get("y", QWEN_CAMERA_SCENE_CENTER_Y)) - float(
|
||||
target.get("y", QWEN_CAMERA_SCENE_CENTER_Y)
|
||||
)
|
||||
dz = float(position.get("z", 0.0)) - float(target.get("z", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
distance = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if distance <= 0:
|
||||
return None
|
||||
horizontal_angle = int(round(math.degrees(math.atan2(dx, dz)))) % 360
|
||||
vertical_angle = int(round(math.degrees(math.asin(max(-1.0, min(1.0, dy / distance))))))
|
||||
zoom = max(0.0, min(10.0, ((2.6 - distance) / 2.0) * 10.0))
|
||||
return horizontal_angle, vertical_angle, round(zoom, 2)
|
||||
|
||||
|
||||
def build_qwen_camera_config_json(
|
||||
qwen_prompt: str = "",
|
||||
camera_info: Any = None,
|
||||
prefer_camera_info: bool = True,
|
||||
camera_mode: str = "standard",
|
||||
subject_focus: str = "auto",
|
||||
lens: str = "auto",
|
||||
orientation: str = "auto",
|
||||
phone_visibility: str = "auto",
|
||||
priority: str = "locked",
|
||||
camera_detail: str = "compact",
|
||||
include_degrees: bool = False,
|
||||
suppress_phone_visibility: bool = True,
|
||||
) -> str:
|
||||
info_values = _qwen_camera_info_values(camera_info)
|
||||
if prefer_camera_info and info_values is not None:
|
||||
horizontal_angle, vertical_angle, zoom = info_values
|
||||
source = "qwen_multiangle_camera_info"
|
||||
else:
|
||||
horizontal_angle, vertical_angle, zoom = _qwen_prompt_camera_values(qwen_prompt)
|
||||
source = "qwen_multiangle_prompt"
|
||||
config = json.loads(
|
||||
build_camera_orbit_config_json(
|
||||
enabled=True,
|
||||
camera_mode=camera_mode,
|
||||
horizontal_angle=horizontal_angle,
|
||||
vertical_angle=vertical_angle,
|
||||
zoom=zoom,
|
||||
framing="from_zoom",
|
||||
subject_focus=subject_focus,
|
||||
lens=lens,
|
||||
orientation=orientation,
|
||||
phone_visibility="auto" if not _is_false(suppress_phone_visibility) else phone_visibility,
|
||||
priority=priority,
|
||||
camera_detail=camera_detail,
|
||||
include_degrees=include_degrees,
|
||||
)
|
||||
)
|
||||
config["camera_source"] = source
|
||||
config["qwen_prompt"] = str(qwen_prompt or "").strip()
|
||||
if info_values is not None:
|
||||
config["qwen_camera_info_values"] = {
|
||||
"horizontal_angle": info_values[0],
|
||||
"vertical_angle": info_values[1],
|
||||
"zoom": info_values[2],
|
||||
}
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
defaults = {
|
||||
"camera_mode": "standard",
|
||||
"shot_size": "auto",
|
||||
"angle": "auto",
|
||||
"lens": "auto",
|
||||
"distance": "auto",
|
||||
"orientation": "auto",
|
||||
"phone_visibility": "auto",
|
||||
"priority": "strong",
|
||||
"camera_detail": "compact",
|
||||
}
|
||||
if not camera_config:
|
||||
return defaults
|
||||
if isinstance(camera_config, dict):
|
||||
raw = camera_config
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(camera_config))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid camera_config JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("camera_config must be a JSON object")
|
||||
parsed = {**defaults, **raw}
|
||||
custom_camera_prompt = _clean_prompt_punctuation(parsed.get("custom_camera_prompt", "")).rstrip(".")
|
||||
camera_source = str(parsed.get("camera_source") or "")
|
||||
normalized = {
|
||||
"camera_mode": _choice(parsed.get("camera_mode"), CAMERA_MODE_PROMPTS, defaults["camera_mode"]),
|
||||
"shot_size": _choice(parsed.get("shot_size"), CAMERA_SHOT_PROMPTS, defaults["shot_size"]),
|
||||
"angle": _choice(parsed.get("angle"), CAMERA_ANGLE_PROMPTS, defaults["angle"]),
|
||||
"lens": _choice(parsed.get("lens"), CAMERA_LENS_PROMPTS, defaults["lens"]),
|
||||
"distance": _choice(parsed.get("distance"), CAMERA_DISTANCE_PROMPTS, defaults["distance"]),
|
||||
"orientation": _choice(parsed.get("orientation"), CAMERA_ORIENTATION_PROMPTS, defaults["orientation"]),
|
||||
"phone_visibility": _choice(parsed.get("phone_visibility"), CAMERA_PHONE_PROMPTS, defaults["phone_visibility"]),
|
||||
"priority": _choice(parsed.get("priority"), CAMERA_PRIORITY_PROMPTS, defaults["priority"]),
|
||||
"camera_detail": str(parsed.get("camera_detail") or defaults["camera_detail"])
|
||||
if str(parsed.get("camera_detail") or defaults["camera_detail"]) in CAMERA_DETAIL_CHOICES
|
||||
else defaults["camera_detail"],
|
||||
}
|
||||
if custom_camera_prompt:
|
||||
normalized["custom_camera_prompt"] = custom_camera_prompt
|
||||
if camera_source:
|
||||
normalized["camera_source"] = camera_source
|
||||
for key in (
|
||||
"orbit_azimuth",
|
||||
"orbit_elevation",
|
||||
"orbit_zoom",
|
||||
"orbit_direction",
|
||||
"orbit_elevation_label",
|
||||
"orbit_distance_label",
|
||||
"orbit_framing",
|
||||
"orbit_focus",
|
||||
):
|
||||
if key in parsed:
|
||||
normalized[key] = parsed[key]
|
||||
return normalized
|
||||
|
||||
|
||||
def camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, Any]:
|
||||
parsed = parse_camera_config(camera_config)
|
||||
if camera_mode and camera_mode != "from_camera_config":
|
||||
parsed["camera_mode"] = _choice(camera_mode, CAMERA_MODE_PROMPTS, parsed["camera_mode"])
|
||||
return parsed
|
||||
|
||||
|
||||
def camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
|
||||
parsed = parse_camera_config(camera_config)
|
||||
if parsed["camera_detail"] == "off" or parsed["camera_mode"] == "disabled":
|
||||
return "", parsed
|
||||
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
|
||||
if parsed["camera_detail"] == "compact":
|
||||
values = [
|
||||
parsed["camera_mode"],
|
||||
parsed["shot_size"],
|
||||
parsed["angle"],
|
||||
parsed["lens"],
|
||||
parsed["distance"],
|
||||
parsed["orientation"],
|
||||
parsed["phone_visibility"],
|
||||
]
|
||||
labels = [CAMERA_COMPACT_LABELS.get(value, value.replace("_", " ")) for value in values]
|
||||
labels = [label for value, label in zip(values, labels) if label and value != "auto"]
|
||||
if custom_camera_prompt:
|
||||
labels.append(custom_camera_prompt)
|
||||
if not labels:
|
||||
return "", parsed
|
||||
directive = "Camera: " + ", ".join(labels) + "."
|
||||
if parsed["priority"] == "locked":
|
||||
directive += " Keep this camera framing."
|
||||
return directive, parsed
|
||||
parts = [
|
||||
CAMERA_MODE_PROMPTS[parsed["camera_mode"]],
|
||||
CAMERA_SHOT_PROMPTS[parsed["shot_size"]],
|
||||
CAMERA_ANGLE_PROMPTS[parsed["angle"]],
|
||||
CAMERA_LENS_PROMPTS[parsed["lens"]],
|
||||
CAMERA_DISTANCE_PROMPTS[parsed["distance"]],
|
||||
CAMERA_ORIENTATION_PROMPTS[parsed["orientation"]],
|
||||
CAMERA_PHONE_PROMPTS[parsed["phone_visibility"]],
|
||||
]
|
||||
if custom_camera_prompt:
|
||||
parts.append(f"Camera orbit: {custom_camera_prompt}.")
|
||||
parts = [part for part in parts if part]
|
||||
if not parts:
|
||||
return "", parsed
|
||||
parts.append(CAMERA_PRIORITY_PROMPTS[parsed["priority"]])
|
||||
return " ".join(parts), parsed
|
||||
|
||||
|
||||
def camera_caption_text(parsed: dict[str, Any]) -> str:
|
||||
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
|
||||
if custom_camera_prompt:
|
||||
return custom_camera_prompt
|
||||
camera_mode = str(parsed.get("camera_mode") or "").replace("_", " ").strip()
|
||||
if not camera_mode or camera_mode == "standard":
|
||||
return ""
|
||||
return f"{camera_mode} camera framing"
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import formatter_input as input_policy
|
||||
from . import formatter_route_trace as trace_policy
|
||||
from . import formatter_target as target_policy
|
||||
except ImportError: # pragma: no cover - plain-script smoke tests
|
||||
import formatter_input as input_policy
|
||||
import formatter_route_trace as trace_policy
|
||||
import formatter_target as target_policy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptionFormatRequest:
|
||||
source_text: str
|
||||
metadata_json: str = ""
|
||||
input_hint: str = "auto"
|
||||
target: str = "auto"
|
||||
trigger: str = ""
|
||||
include_trigger: bool = True
|
||||
detail_level: str = "balanced"
|
||||
style_policy: str = "drop_style_tail"
|
||||
caption_profile: str = "manual_controls"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptionFormatRoute:
|
||||
caption: str
|
||||
method: str
|
||||
branch: str
|
||||
input_hint: str
|
||||
target: str
|
||||
detail_level: str
|
||||
style_policy: str
|
||||
include_trigger: bool
|
||||
keep_style: bool
|
||||
route_trace_json: str = ""
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.caption, self.method
|
||||
|
||||
def as_trace_tuple(self) -> tuple[str, str, str]:
|
||||
return self.caption, self.method, self.route_trace_json
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptionFormatDependencies:
|
||||
apply_caption_profile: Callable[[str, str, str, bool], tuple[str, str, bool]]
|
||||
keep_style_terms: Callable[[str], bool]
|
||||
row_from_inputs: Callable[[str, str, str], tuple[dict[str, Any] | None, str]]
|
||||
metadata_to_prose: Callable[..., tuple[str, str]]
|
||||
text_to_prose: Callable[[str, str, bool], tuple[str, str]]
|
||||
with_trigger: Callable[[str, str, bool], str]
|
||||
sanitize_prose_text: Callable[..., str]
|
||||
|
||||
|
||||
def naturalize_caption_result(
|
||||
request: CaptionFormatRequest,
|
||||
deps: CaptionFormatDependencies,
|
||||
) -> CaptionFormatRoute:
|
||||
input_hint = input_policy.normalize_input_hint(request.input_hint, text_hint=input_policy.INPUT_HINT_CAPTION_OR_PROMPT)
|
||||
target = target_policy.normalize_target(request.target)
|
||||
detail_level, style_policy, include_trigger = deps.apply_caption_profile(
|
||||
request.caption_profile,
|
||||
request.detail_level,
|
||||
request.style_policy,
|
||||
request.include_trigger,
|
||||
)
|
||||
keep_style = deps.keep_style_terms(style_policy)
|
||||
row, row_method = deps.row_from_inputs(request.source_text, request.metadata_json, input_hint)
|
||||
if row is not None:
|
||||
prose, method = deps.metadata_to_prose(row, detail_level, keep_style, target)
|
||||
caption = deps.sanitize_prose_text(
|
||||
deps.with_trigger(prose, request.trigger, include_trigger),
|
||||
triggers=(request.trigger,),
|
||||
)
|
||||
full_method = f"{row_method}:{method}"
|
||||
route_trace = trace_policy.route_trace_json(
|
||||
formatter="caption",
|
||||
branch="metadata",
|
||||
method=full_method,
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
include_trigger=include_trigger,
|
||||
keep_style=keep_style,
|
||||
**trace_policy.metadata_trace_fields(row, target=target),
|
||||
)
|
||||
return CaptionFormatRoute(
|
||||
caption=caption,
|
||||
method=full_method,
|
||||
branch="metadata",
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
include_trigger=include_trigger,
|
||||
keep_style=keep_style,
|
||||
route_trace_json=route_trace,
|
||||
)
|
||||
|
||||
prose, method = deps.text_to_prose(request.source_text, detail_level, keep_style)
|
||||
caption = deps.sanitize_prose_text(
|
||||
deps.with_trigger(prose, request.trigger, include_trigger),
|
||||
triggers=(request.trigger,),
|
||||
)
|
||||
route_trace = trace_policy.route_trace_json(
|
||||
formatter="caption",
|
||||
branch="text",
|
||||
method=method,
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
include_trigger=include_trigger,
|
||||
keep_style=keep_style,
|
||||
)
|
||||
return CaptionFormatRoute(
|
||||
caption=caption,
|
||||
method=method,
|
||||
branch="text",
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
include_trigger=include_trigger,
|
||||
keep_style=keep_style,
|
||||
route_trace_json=route_trace,
|
||||
)
|
||||
|
||||
|
||||
def naturalize_caption(
|
||||
request: CaptionFormatRequest,
|
||||
deps: CaptionFormatDependencies,
|
||||
) -> tuple[str, str]:
|
||||
return naturalize_caption_result(request, deps).as_tuple()
|
||||
@@ -0,0 +1,469 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import formatter_input as input_policy
|
||||
from . import formatter_target as target_policy
|
||||
except ImportError: # pragma: no cover - plain-script smoke tests
|
||||
import formatter_input as input_policy
|
||||
import formatter_target as target_policy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptionMetadataRouteRequest:
|
||||
row: dict[str, Any]
|
||||
detail_level: str
|
||||
keep_style: bool
|
||||
target: str = "auto"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptionMetadataRoute:
|
||||
prose: str
|
||||
method: str
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.prose, self.method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptionMetadataRouteDependencies:
|
||||
item_labels: tuple[str, ...]
|
||||
clean_text: Callable[[Any], str]
|
||||
row_value: Callable[[dict[str, Any], str, tuple[str, ...]], str]
|
||||
field_row_value: Callable[[dict[str, Any], str], str]
|
||||
clean_clothing: Callable[[str], str]
|
||||
normalize_composition: Callable[[str], str]
|
||||
expression_disabled: Callable[[dict[str, Any]], bool]
|
||||
detail_allows: Callable[..., bool]
|
||||
join_sentences: Callable[[list[str]], str]
|
||||
human_join: Callable[[list[str]], str]
|
||||
article: Callable[[str], str]
|
||||
cap_first: Callable[[str], str]
|
||||
body_phrase: Callable[[Any, Any], str]
|
||||
single_caption_front: Callable[[dict[str, Any]], dict[str, str]]
|
||||
pose_clause: Callable[[str], str]
|
||||
age_subject: Callable[[str, str], str]
|
||||
clean_age_phrase: Callable[[str], str]
|
||||
subject_phrase_from_counts: Callable[[dict[str, Any]], str]
|
||||
verb_for_row: Callable[[dict[str, Any]], str]
|
||||
metadata_action_label: Callable[[dict[str, Any]], str]
|
||||
item_axis_detail_text: Callable[[dict[str, Any], str], str]
|
||||
natural_cast_descriptor_text: Callable[[str], str]
|
||||
cast_labels: Callable[[str], list[str]]
|
||||
natural_label_text: Callable[[Any, list[str]], str]
|
||||
softcore_caption_setup_phrase: Callable[..., str]
|
||||
metadata_to_prose: Callable[..., tuple[str, str]]
|
||||
|
||||
|
||||
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_clothing_sentence(clothing: str, clean_text: Callable[[Any], str]) -> str:
|
||||
clothing = clean_text(clothing)
|
||||
lower = clothing.lower()
|
||||
partner_text = re.sub(r"\bPartner ([AB]) wears\b", r"Partner \1 wearing", clothing)
|
||||
partner_text = re.sub(r"\bPartner ([AB]) has\b", r"Partner \1 with", partner_text)
|
||||
if lower.startswith("partner a "):
|
||||
return f"The outfits show {partner_text}"
|
||||
if lower.startswith(("two ", "paired ", "coordinated ")):
|
||||
return f"The outfits are {partner_text}"
|
||||
return f"They wear {clothing}"
|
||||
|
||||
|
||||
def couple_subject_sentence(
|
||||
subject: str,
|
||||
ages: str,
|
||||
cap_first: Callable[[str], str],
|
||||
clean_age_phrase: Callable[[str], str],
|
||||
) -> str:
|
||||
subject = cap_first(subject or "adult couple")
|
||||
ages = clean_age_phrase(ages)
|
||||
if ages:
|
||||
return f"{subject}, {ages}"
|
||||
if subject.lower() == "adult couple":
|
||||
return subject
|
||||
return f"{subject} are adults"
|
||||
|
||||
|
||||
def expression_detail(expression: Any, clean_text: Callable[[Any], str]) -> tuple[str, bool]:
|
||||
text = clean_text(expression)
|
||||
if not text:
|
||||
return "", False
|
||||
has_character_labels = bool(
|
||||
re.search(
|
||||
r"\b(?:Woman|Man) [A-Z] has\b|\bthe (?:woman|man) has\b",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
text = re.sub(
|
||||
r"\b((?:Woman|Man) [A-Z]|the (?:woman|man)) has\b",
|
||||
r"\1 with",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return text, has_character_labels
|
||||
|
||||
|
||||
def single_from_row_result(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> CaptionMetadataRoute | None:
|
||||
row = request.row
|
||||
detail_level = request.detail_level
|
||||
keep_style = request.keep_style
|
||||
subject = deps.clean_text(row.get("primary_subject") or row.get("subject") or "")
|
||||
if subject not in ("woman", "man"):
|
||||
return None
|
||||
|
||||
caption_front = deps.single_caption_front(row)
|
||||
age = deps.clean_text(row.get("age") or row.get("age_band") or caption_front.get("caption_age") or "")
|
||||
body_phrase = deps.field_row_value(row, "body_phrase") or caption_front.get("caption_body_phrase", "")
|
||||
if not body_phrase:
|
||||
body = deps.clean_text(row.get("body_type") or row.get("body") or "")
|
||||
figure = deps.clean_text(row.get("figure"))
|
||||
body_phrase = deps.body_phrase(body, figure)
|
||||
|
||||
skin = deps.field_row_value(row, "skin") or caption_front.get("caption_skin", "")
|
||||
hair = deps.field_row_value(row, "hair") or caption_front.get("caption_hair", "")
|
||||
eyes = deps.field_row_value(row, "eyes") or caption_front.get("caption_eyes", "")
|
||||
item = deps.row_value(row, "item", deps.item_labels)
|
||||
if item:
|
||||
item = deps.clean_clothing(item)
|
||||
if not item:
|
||||
item = deps.clean_clothing(deps.row_value(row, "clothing", ("Clothing", "Erotic outfit")))
|
||||
scene = deps.row_value(row, "scene_text", ("Scene", "Setting"))
|
||||
pose = deps.row_value(row, "pose", ("Pose",))
|
||||
expression = "" if deps.expression_disabled(row) else deps.row_value(row, "expression", ("Facial expression", "Facial expressions"))
|
||||
composition = deps.normalize_composition(deps.row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = deps.clean_text(row.get("camera_scene_directive"))
|
||||
prop = deps.row_value(row, "prop", ("Prop/detail",))
|
||||
style = deps.field_row_value(row, "style") if keep_style else ""
|
||||
|
||||
parts = []
|
||||
opener = deps.age_subject(age, subject)
|
||||
appearance_details = [piece for piece in (skin, hair, eyes) if piece]
|
||||
if body_phrase:
|
||||
parts.append(f"{opener} has {deps.article(body_phrase)} {body_phrase}")
|
||||
elif appearance_details:
|
||||
parts.append(f"{opener} has {deps.human_join(appearance_details)}")
|
||||
else:
|
||||
parts.append(opener)
|
||||
if body_phrase and appearance_details:
|
||||
parts.append(f"{pronoun(subject)} has {deps.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 {deps.pose_clause(pose)}")
|
||||
if expression:
|
||||
expression, labeled_expression = expression_detail(expression, deps.clean_text)
|
||||
if labeled_expression:
|
||||
parts.append(f"The expression detail shows {expression}")
|
||||
else:
|
||||
parts.append(f"{possessive_pronoun(subject)} expression is {expression}")
|
||||
if scene:
|
||||
parts.append(f"The setting is {scene}")
|
||||
if deps.detail_allows(detail_level) and camera_scene:
|
||||
parts.append(camera_scene)
|
||||
if deps.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 CaptionMetadataRoute(deps.join_sentences(parts), "metadata(single)")
|
||||
|
||||
|
||||
def couple_from_row_result(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> CaptionMetadataRoute | None:
|
||||
row = request.row
|
||||
detail_level = request.detail_level
|
||||
keep_style = request.keep_style
|
||||
subject = deps.clean_text(row.get("subject_phrase") or row.get("primary_subject"))
|
||||
primary = deps.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 = deps.row_value(row, "age", ("Ages",)) or deps.clean_text(row.get("age_band"))
|
||||
body = deps.row_value(row, "body", ("Body types",)) or deps.clean_text(row.get("body_type"))
|
||||
pose = deps.row_value(row, "pose", ("Pose",))
|
||||
pose = pose.replace(", affectionate and flirtatious but non-explicit", "")
|
||||
clothing = deps.clean_clothing(deps.row_value(row, "item", deps.item_labels) or deps.row_value(row, "clothing", ("Clothing",)))
|
||||
scene = deps.row_value(row, "scene_text", ("Scene", "Setting"))
|
||||
expression = ""
|
||||
if not deps.expression_disabled(row):
|
||||
expression = deps.row_value(row, "character_expression_text") or deps.row_value(
|
||||
row,
|
||||
"expression",
|
||||
("Facial expressions", "Facial expression"),
|
||||
)
|
||||
composition = deps.normalize_composition(deps.row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = deps.clean_text(row.get("camera_scene_directive"))
|
||||
style = deps.field_row_value(row, "style") if keep_style else ""
|
||||
|
||||
parts = [couple_subject_sentence(subject, ages, deps.cap_first, deps.clean_age_phrase)]
|
||||
if body:
|
||||
parts.append(f"Their body types are {body}")
|
||||
if clothing:
|
||||
parts.append(couple_clothing_sentence(clothing, deps.clean_text))
|
||||
if pose:
|
||||
parts.append(f"The pose is {pose}")
|
||||
if scene:
|
||||
parts.append(f"The setting is {scene}")
|
||||
if deps.detail_allows(detail_level) and camera_scene:
|
||||
parts.append(camera_scene)
|
||||
if expression:
|
||||
expression, labeled_expression = expression_detail(expression, deps.clean_text)
|
||||
if labeled_expression:
|
||||
parts.append(f"The expression details show {expression}")
|
||||
else:
|
||||
parts.append(f"Their expressions are {expression}")
|
||||
if deps.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 CaptionMetadataRoute(deps.join_sentences(parts), "metadata(couple)")
|
||||
|
||||
|
||||
def configured_cast_from_row_result(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> CaptionMetadataRoute | None:
|
||||
row = request.row
|
||||
detail_level = request.detail_level
|
||||
keep_style = request.keep_style
|
||||
if deps.clean_text(row.get("subject_type")) != "configured_cast":
|
||||
if "hardcore sexual poses" not in deps.clean_text(row.get("main_category")).lower():
|
||||
return None
|
||||
|
||||
subject = deps.subject_phrase_from_counts(row)
|
||||
verb = deps.verb_for_row(row)
|
||||
cast = deps.row_value(row, "cast_summary", ("Cast",))
|
||||
role_graph = deps.row_value(row, "role_graph", ("Role graph",))
|
||||
item = deps.row_value(row, "item", deps.item_labels)
|
||||
axis_detail = deps.item_axis_detail_text(row, " ".join(part for part in (role_graph, item) if part))
|
||||
scene = deps.row_value(row, "scene_text", ("Setting", "Scene"))
|
||||
expression = ""
|
||||
if not deps.expression_disabled(row):
|
||||
expression = deps.row_value(row, "character_expression_text") or deps.row_value(
|
||||
row,
|
||||
"expression",
|
||||
("Facial expressions", "Facial expression"),
|
||||
)
|
||||
composition = deps.normalize_composition(deps.row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = deps.clean_text(row.get("camera_scene_directive"))
|
||||
cast_descriptor_text = deps.row_value(row, "cast_descriptor_text", ("Characters", "Cast descriptors"))
|
||||
scene_kind = deps.field_row_value(row, "scene_kind") or "explicit adult sex scene"
|
||||
style = deps.field_row_value(row, "style") if keep_style else ""
|
||||
|
||||
parts = [f"{deps.cap_first(subject)} {verb} shown as a consensual {scene_kind}"]
|
||||
if cast_descriptor_text:
|
||||
parts.append(deps.natural_cast_descriptor_text(cast_descriptor_text))
|
||||
if cast and not cast_descriptor_text:
|
||||
parts.append(f"The cast is {cast}")
|
||||
if role_graph:
|
||||
parts.append(role_graph)
|
||||
if item:
|
||||
parts.append(f"The {deps.metadata_action_label(row)} is {item}")
|
||||
if axis_detail:
|
||||
parts.append(f"Selected action details include {axis_detail}")
|
||||
scene_bits = []
|
||||
if scene:
|
||||
scene_bits.append(f"set in {scene}")
|
||||
if expression:
|
||||
expression, labeled_expression = expression_detail(expression, deps.clean_text)
|
||||
if labeled_expression:
|
||||
scene_bits.append(f"showing {expression}")
|
||||
else:
|
||||
scene_bits.append(f"with {expression}")
|
||||
if composition:
|
||||
scene_bits.append(f"framed as {composition}")
|
||||
if scene_bits and deps.detail_allows(detail_level):
|
||||
parts.append(", ".join(scene_bits))
|
||||
if deps.detail_allows(detail_level) and camera_scene:
|
||||
parts.append(camera_scene)
|
||||
if keep_style and style:
|
||||
parts.append(f"The visual style is {style}")
|
||||
return CaptionMetadataRoute(deps.join_sentences(parts), "metadata(configured_cast)")
|
||||
|
||||
|
||||
def group_or_layout_from_row_result(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> CaptionMetadataRoute | None:
|
||||
row = request.row
|
||||
detail_level = request.detail_level
|
||||
keep_style = request.keep_style
|
||||
primary = deps.clean_text(row.get("primary_subject"))
|
||||
if "group" not in primary and primary != "layout scene":
|
||||
return None
|
||||
|
||||
subject = deps.field_row_value(row, "subject_phrase") or primary
|
||||
age = deps.row_value(row, "age", ("Ages",)) or deps.clean_text(row.get("age_band"))
|
||||
item = deps.clean_clothing(deps.row_value(row, "item", deps.item_labels) or deps.row_value(row, "clothing", ("Clothing",)))
|
||||
scene = deps.row_value(row, "scene_text", ("Scene", "Setting"))
|
||||
expression = ""
|
||||
if not deps.expression_disabled(row):
|
||||
expression = deps.row_value(row, "character_expression_text") or deps.row_value(
|
||||
row,
|
||||
"expression",
|
||||
("Facial expressions", "Facial expression"),
|
||||
)
|
||||
composition = deps.normalize_composition(deps.row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = deps.clean_text(row.get("camera_scene_directive"))
|
||||
style = deps.field_row_value(row, "style") if keep_style else ""
|
||||
|
||||
if primary == "layout scene":
|
||||
parts = [f"{deps.cap_first(subject)} is arranged as an adults-only designed illustration layout"]
|
||||
if expression:
|
||||
expression, labeled_expression = expression_detail(expression, deps.clean_text)
|
||||
if labeled_expression:
|
||||
parts.append(f"The featured expression details show {expression}")
|
||||
else:
|
||||
parts.append(f"The featured expression is {expression}")
|
||||
else:
|
||||
parts = [f"{deps.cap_first(subject)} includes adults"]
|
||||
if age:
|
||||
parts[0] += f" ages {age}"
|
||||
if item:
|
||||
parts.append(f"They wear {item}")
|
||||
if expression:
|
||||
expression, labeled_expression = expression_detail(expression, deps.clean_text)
|
||||
if labeled_expression:
|
||||
parts.append(f"Their expressions show {expression}")
|
||||
else:
|
||||
parts.append(f"They show {expression}")
|
||||
if scene:
|
||||
parts.append(f"The setting is {scene}")
|
||||
if deps.detail_allows(detail_level) and camera_scene:
|
||||
parts.append(camera_scene)
|
||||
if deps.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 CaptionMetadataRoute(deps.join_sentences(parts), "metadata(group_layout)")
|
||||
|
||||
|
||||
def insta_of_pair_from_row_result(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> CaptionMetadataRoute | None:
|
||||
row = request.row
|
||||
detail_level = request.detail_level
|
||||
keep_style = request.keep_style
|
||||
pair_target = target_policy.pair_policy(request.target)
|
||||
target = pair_target.pair_target
|
||||
if not input_policy.is_pair_metadata(row):
|
||||
return None
|
||||
soft_row = row.get("softcore_row")
|
||||
hard_row = row.get("hardcore_row")
|
||||
if not isinstance(soft_row, dict) or not isinstance(hard_row, dict):
|
||||
return None
|
||||
|
||||
hard_row_for_text = dict(hard_row)
|
||||
options = row.get("options")
|
||||
if isinstance(options, dict) and options.get("continuity") == "same_creator_same_room":
|
||||
if not hard_row_for_text.get("scene_text") and soft_row.get("scene_text"):
|
||||
hard_row_for_text["scene_text"] = soft_row["scene_text"]
|
||||
if not hard_row_for_text.get("composition") and soft_row.get("composition"):
|
||||
hard_row_for_text["composition"] = soft_row["composition"]
|
||||
|
||||
include_soft = pair_target.include_softcore
|
||||
include_hard = pair_target.include_hardcore
|
||||
soft_text = ""
|
||||
hard_text = ""
|
||||
if include_soft:
|
||||
soft_text, _soft_method = deps.metadata_to_prose(soft_row, detail_level, keep_style, "single")
|
||||
if include_hard:
|
||||
hard_text, _hard_method = deps.metadata_to_prose(hard_row_for_text, detail_level, keep_style, "single")
|
||||
descriptor = deps.clean_text(row.get("shared_descriptor"))
|
||||
options = row.get("options") if isinstance(row.get("options"), dict) else {}
|
||||
cast_descriptors = row.get("shared_cast_descriptors")
|
||||
if isinstance(cast_descriptors, list):
|
||||
cast_descriptor_text = "; ".join(deps.clean_text(item) for item in cast_descriptors if deps.clean_text(item))
|
||||
else:
|
||||
cast_descriptor_text = deps.clean_text(cast_descriptors)
|
||||
labels = deps.cast_labels(cast_descriptor_text)
|
||||
|
||||
same_soft_cast = options.get("softcore_cast") == "same_as_hardcore"
|
||||
|
||||
parts = []
|
||||
if not soft_text and not hard_text:
|
||||
if cast_descriptor_text:
|
||||
parts.append(deps.natural_cast_descriptor_text(cast_descriptor_text))
|
||||
elif descriptor:
|
||||
parts.append(f"A {descriptor}")
|
||||
if same_soft_cast and include_soft:
|
||||
parts.append(
|
||||
deps.softcore_caption_setup_phrase(
|
||||
same_cast=True,
|
||||
target_auto=target == "auto",
|
||||
)
|
||||
)
|
||||
partner_styling = row.get("softcore_partner_styling")
|
||||
if isinstance(partner_styling, dict):
|
||||
outfits = partner_styling.get("outfits")
|
||||
if isinstance(outfits, list):
|
||||
outfit_text = deps.human_join([deps.clean_text(item) for item in outfits if deps.clean_text(item)])
|
||||
outfit_text = deps.natural_label_text(outfit_text, labels)
|
||||
if outfit_text:
|
||||
parts.append(f"Softcore partner styling: {outfit_text}")
|
||||
pose = deps.clean_text(partner_styling.get("pose"))
|
||||
if pose:
|
||||
parts.append(f"The shared softcore cast pose is {pose}")
|
||||
if soft_text:
|
||||
parts.append(f"Softcore side: {soft_text}" if target == "auto" else soft_text)
|
||||
if hard_text:
|
||||
parts.append(f"Hardcore side: {hard_text}" if target == "auto" else hard_text)
|
||||
if not parts:
|
||||
return None
|
||||
return CaptionMetadataRoute(deps.join_sentences(parts), "metadata(insta_of_pair)")
|
||||
|
||||
|
||||
def single_from_row(request: CaptionMetadataRouteRequest, deps: CaptionMetadataRouteDependencies) -> tuple[str, str] | None:
|
||||
result = single_from_row_result(request, deps)
|
||||
return result.as_tuple() if result else None
|
||||
|
||||
|
||||
def couple_from_row(request: CaptionMetadataRouteRequest, deps: CaptionMetadataRouteDependencies) -> tuple[str, str] | None:
|
||||
result = couple_from_row_result(request, deps)
|
||||
return result.as_tuple() if result else None
|
||||
|
||||
|
||||
def configured_cast_from_row(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> tuple[str, str] | None:
|
||||
result = configured_cast_from_row_result(request, deps)
|
||||
return result.as_tuple() if result else None
|
||||
|
||||
|
||||
def group_or_layout_from_row(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> tuple[str, str] | None:
|
||||
result = group_or_layout_from_row_result(request, deps)
|
||||
return result.as_tuple() if result else None
|
||||
|
||||
|
||||
def insta_of_pair_from_row(
|
||||
request: CaptionMetadataRouteRequest,
|
||||
deps: CaptionMetadataRouteDependencies,
|
||||
) -> tuple[str, str] | None:
|
||||
result = insta_of_pair_from_row_result(request, deps)
|
||||
return result.as_tuple() if result else None
|
||||
+201
-511
@@ -1,628 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import caption_format_route
|
||||
from . import caption_metadata_routes
|
||||
from . import caption_policy
|
||||
from . import caption_text_policy
|
||||
from . import formatter_input as input_policy
|
||||
from .prompt_hygiene import sanitize_prose_text
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
import caption_format_route
|
||||
import caption_metadata_routes
|
||||
import caption_policy
|
||||
import caption_text_policy
|
||||
import formatter_input as input_policy
|
||||
from prompt_hygiene import sanitize_prose_text
|
||||
|
||||
|
||||
OLD_TRIGGER = "sxcpinup_coloredpencil"
|
||||
DEFAULT_TRIGGER = "sxcppnl7"
|
||||
OLD_TRIGGER = caption_policy.OLD_TRIGGER
|
||||
DEFAULT_TRIGGER = caption_policy.DEFAULT_TRIGGER
|
||||
STYLE_TAILS = caption_policy.STYLE_TAILS
|
||||
|
||||
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",
|
||||
"Cast descriptors",
|
||||
"Characters",
|
||||
"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",
|
||||
)
|
||||
PROMPT_FIELD_LABELS = caption_text_policy.PROMPT_FIELD_LABELS
|
||||
ITEM_LABELS = caption_policy.ITEM_LABELS
|
||||
ACTION_FAMILY_CAPTION_LABELS = caption_policy.ACTION_FAMILY_CAPTION_LABELS
|
||||
POSITION_FAMILY_CAPTION_LABELS = caption_policy.POSITION_FAMILY_CAPTION_LABELS
|
||||
|
||||
|
||||
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
|
||||
return caption_text_policy.clean_text(value)
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
return caption_text_policy.is_false(value)
|
||||
|
||||
|
||||
def _expression_disabled(row: dict[str, Any]) -> bool:
|
||||
return bool(row.get("expression_disabled")) or _is_false(row.get("expression_enabled", True))
|
||||
return caption_text_policy.expression_disabled(row)
|
||||
|
||||
|
||||
def _cap_first(text: str) -> str:
|
||||
text = _clean_text(text).strip(" ,")
|
||||
return text[:1].upper() + text[1:] if text else ""
|
||||
return caption_text_policy.cap_first(text)
|
||||
|
||||
|
||||
def _article(noun_phrase: str) -> str:
|
||||
word = noun_phrase.lstrip().lower()
|
||||
if word.startswith("hour") or word[:1] in "aeiou":
|
||||
return "an"
|
||||
return "a"
|
||||
return caption_text_policy.article(noun_phrase)
|
||||
|
||||
|
||||
def _sentence(text: str) -> str:
|
||||
text = _clean_text(text).strip(" ,;")
|
||||
if not text:
|
||||
return ""
|
||||
if text[-1] not in ".!?":
|
||||
text += "."
|
||||
return _cap_first(text)
|
||||
return caption_text_policy.sentence(text)
|
||||
|
||||
|
||||
def _join_sentences(parts: list[str]) -> str:
|
||||
return " ".join(part for part in (_sentence(part) for part in parts) if part)
|
||||
return caption_text_policy.join_sentences(parts)
|
||||
|
||||
|
||||
def _formatter_hint_parts(row: dict[str, Any]) -> list[str]:
|
||||
return caption_text_policy.formatter_hint_parts(row)
|
||||
|
||||
|
||||
def _append_formatter_hints(prose: str, row: dict[str, Any]) -> str:
|
||||
return caption_text_policy.append_formatter_hints(prose, row)
|
||||
|
||||
|
||||
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]}"
|
||||
return caption_text_policy.human_join(parts)
|
||||
|
||||
|
||||
def _metadata_action_label(row: dict[str, Any], default: str = "sexual pose") -> str:
|
||||
return caption_text_policy.metadata_action_label(row, default)
|
||||
|
||||
|
||||
def _prompt_cast_descriptors(text: str) -> str:
|
||||
return _clean_text(text).replace("Woman A / primary creator:", "Woman A:")
|
||||
return caption_text_policy.prompt_cast_descriptors(text)
|
||||
|
||||
|
||||
def _cast_entries(text: str) -> list[tuple[str, str]]:
|
||||
text = _prompt_cast_descriptors(text)
|
||||
entries: list[tuple[str, str]] = []
|
||||
for part in text.split(";"):
|
||||
part = _clean_text(part)
|
||||
match = re.match(r"^((?:Woman|Man) [A-Z]):\s*(.+)$", part)
|
||||
if match:
|
||||
entries.append((match.group(1), _clean_text(match.group(2))))
|
||||
return entries
|
||||
return caption_text_policy.cast_entries(text)
|
||||
|
||||
|
||||
def _natural_cast_descriptor_text(text: str) -> str:
|
||||
entries = _cast_entries(text)
|
||||
if not entries:
|
||||
return _clean_text(text)
|
||||
labels = [label for label, _descriptor in entries]
|
||||
if labels == ["Woman A"] or labels == ["Man A"]:
|
||||
return f"A {entries[0][1]}"
|
||||
if set(labels) == {"Woman A", "Man A"} and len(labels) == 2:
|
||||
by_label = {label: descriptor for label, descriptor in entries}
|
||||
return f"A {by_label['Woman A']} alongside a {by_label['Man A']}"
|
||||
return " ".join(f"{label} is {descriptor}." for label, descriptor in entries)
|
||||
return caption_text_policy.natural_cast_descriptor_text(text)
|
||||
|
||||
|
||||
def _cast_labels(text: str) -> list[str]:
|
||||
return [label for label, _descriptor in _cast_entries(text)]
|
||||
return caption_text_policy.cast_labels(text)
|
||||
|
||||
|
||||
def _natural_label_text(text: Any, labels: list[str]) -> str:
|
||||
text = _clean_text(text)
|
||||
if not text:
|
||||
return ""
|
||||
if set(labels) == {"Woman A", "Man A"}:
|
||||
text = re.sub(r"\bWoman A\b", "the woman", text)
|
||||
text = re.sub(r"\bMan A\b", "the man", text)
|
||||
elif labels == ["Woman A"]:
|
||||
text = re.sub(r"\bWoman A\b", "the woman", text)
|
||||
elif labels == ["Man A"]:
|
||||
text = re.sub(r"\bMan A\b", "the man", text)
|
||||
return text
|
||||
return caption_text_policy.natural_label_text(text, labels)
|
||||
|
||||
|
||||
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
|
||||
return caption_text_policy.strip_style_tail(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
|
||||
return caption_text_policy.remove_trigger(text, trigger)
|
||||
|
||||
|
||||
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}"
|
||||
return caption_text_policy.with_trigger(text, trigger, include_trigger)
|
||||
|
||||
|
||||
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
|
||||
return input_policy.maybe_json(text)
|
||||
|
||||
|
||||
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"
|
||||
return input_policy.row_from_inputs(source_text, metadata_json, input_hint)
|
||||
|
||||
|
||||
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(".")
|
||||
return caption_text_policy.prompt_field(text, label)
|
||||
|
||||
|
||||
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 ""
|
||||
return caption_text_policy.row_value(row, key, labels)
|
||||
|
||||
|
||||
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 ""
|
||||
return caption_text_policy.field_from_any_prompt(text, labels)
|
||||
|
||||
|
||||
def _normalize_composition(text: str) -> str:
|
||||
return re.sub(r"^vertical\s+", "", _clean_text(text), flags=re.IGNORECASE)
|
||||
return caption_text_policy.normalize_composition(text)
|
||||
|
||||
|
||||
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(" ,")
|
||||
return caption_text_policy.clean_clothing(text)
|
||||
|
||||
|
||||
def _body_phrase(body: Any, figure_note: Any = "") -> str:
|
||||
body = _clean_text(body)
|
||||
figure_note = _clean_text(figure_note)
|
||||
if not body:
|
||||
return figure_note
|
||||
if not figure_note:
|
||||
return f"{body} figure"
|
||||
if "figure" in figure_note.lower():
|
||||
return f"{body} build and {figure_note}"
|
||||
return f"{body} figure with {figure_note}"
|
||||
return caption_text_policy.body_phrase(body, figure_note)
|
||||
|
||||
|
||||
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 = _body_phrase(body, figure)
|
||||
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,
|
||||
}
|
||||
return caption_text_policy.single_caption_front(row)
|
||||
|
||||
|
||||
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}"
|
||||
return caption_text_policy.pose_clause(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}"
|
||||
return caption_text_policy.age_subject(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")
|
||||
return caption_text_policy.clean_age_phrase(age)
|
||||
|
||||
|
||||
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)
|
||||
return caption_text_policy.subject_phrase_from_counts(row)
|
||||
|
||||
|
||||
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"
|
||||
return caption_text_policy.verb_for_row(row)
|
||||
|
||||
|
||||
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"
|
||||
return caption_text_policy.detail_allows(level, dense_only=dense_only)
|
||||
|
||||
|
||||
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
|
||||
def _caption_metadata_route_dependencies() -> caption_metadata_routes.CaptionMetadataRouteDependencies:
|
||||
return caption_text_policy.metadata_route_dependencies(_metadata_to_prose)
|
||||
|
||||
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 = _body_phrase(body, figure)
|
||||
|
||||
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 = "" if _expression_disabled(row) else _row_value(row, "expression", ("Facial expression", "Facial expressions"))
|
||||
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = _clean_text(row.get("camera_scene_directive"))
|
||||
prop = _row_value(row, "prop", ("Prop/detail",))
|
||||
style = _row_value(row, "style") if keep_style else ""
|
||||
def _caption_metadata_route_request(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> caption_metadata_routes.CaptionMetadataRouteRequest:
|
||||
return caption_metadata_routes.CaptionMetadataRouteRequest(
|
||||
row=row,
|
||||
detail_level=detail_level,
|
||||
keep_style=keep_style,
|
||||
target=target,
|
||||
)
|
||||
|
||||
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 camera_scene:
|
||||
parts.append(camera_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 _single_from_row(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> tuple[str, str] | None:
|
||||
return caption_metadata_routes.single_from_row(
|
||||
_caption_metadata_route_request(row, detail_level, keep_style, target),
|
||||
_caption_metadata_route_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
def pronoun(subject: str) -> str:
|
||||
return "She" if subject == "woman" else "He"
|
||||
return caption_metadata_routes.pronoun(subject)
|
||||
|
||||
|
||||
def possessive_pronoun(subject: str) -> str:
|
||||
return "Her" if subject == "woman" else "His"
|
||||
return caption_metadata_routes.possessive_pronoun(subject)
|
||||
|
||||
|
||||
def _couple_clothing_sentence(clothing: str) -> str:
|
||||
clothing = _clean_text(clothing)
|
||||
lower = clothing.lower()
|
||||
partner_text = re.sub(r"\bPartner ([AB]) wears\b", r"Partner \1 wearing", clothing)
|
||||
partner_text = re.sub(r"\bPartner ([AB]) has\b", r"Partner \1 with", partner_text)
|
||||
if lower.startswith("partner a "):
|
||||
return f"The outfits show {partner_text}"
|
||||
if lower.startswith(("two ", "paired ", "coordinated ")):
|
||||
return f"The outfits are {partner_text}"
|
||||
return f"They wear {clothing}"
|
||||
return caption_metadata_routes.couple_clothing_sentence(clothing, _clean_text)
|
||||
|
||||
|
||||
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 = ""
|
||||
if not _expression_disabled(row):
|
||||
expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression"))
|
||||
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = _clean_text(row.get("camera_scene_directive"))
|
||||
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(_couple_clothing_sentence(clothing))
|
||||
if pose:
|
||||
parts.append(f"The pose is {pose}")
|
||||
if scene:
|
||||
parts.append(f"The setting is {scene}")
|
||||
if _detail_allows(detail_level) and camera_scene:
|
||||
parts.append(camera_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 _couple_from_row(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> tuple[str, str] | None:
|
||||
return caption_metadata_routes.couple_from_row(
|
||||
_caption_metadata_route_request(row, detail_level, keep_style, target),
|
||||
_caption_metadata_route_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
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":
|
||||
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 = ""
|
||||
if not _expression_disabled(row):
|
||||
expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression"))
|
||||
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = _clean_text(row.get("camera_scene_directive"))
|
||||
cast_descriptor_text = _row_value(row, "cast_descriptor_text", ("Characters", "Cast descriptors"))
|
||||
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}"]
|
||||
if cast_descriptor_text:
|
||||
parts.append(_natural_cast_descriptor_text(cast_descriptor_text))
|
||||
if cast and not cast_descriptor_text:
|
||||
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 _detail_allows(detail_level) and camera_scene:
|
||||
parts.append(camera_scene)
|
||||
if keep_style and style:
|
||||
parts.append(f"The visual style is {style}")
|
||||
return _join_sentences(parts), "metadata(configured_cast)"
|
||||
def _configured_cast_from_row(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> tuple[str, str] | None:
|
||||
return caption_metadata_routes.configured_cast_from_row(
|
||||
_caption_metadata_route_request(row, detail_level, keep_style, target),
|
||||
_caption_metadata_route_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
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 = ""
|
||||
if not _expression_disabled(row):
|
||||
expression = _row_value(row, "character_expression_text") or _row_value(row, "expression", ("Facial expressions", "Facial expression"))
|
||||
composition = _normalize_composition(_row_value(row, "composition", ("Composition",)))
|
||||
camera_scene = _clean_text(row.get("camera_scene_directive"))
|
||||
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 camera_scene:
|
||||
parts.append(camera_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 _group_or_layout_from_row(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> tuple[str, str] | None:
|
||||
return caption_metadata_routes.group_or_layout_from_row(
|
||||
_caption_metadata_route_request(row, detail_level, keep_style, target),
|
||||
_caption_metadata_route_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
def _insta_of_pair_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
|
||||
if _clean_text(row.get("mode")).lower() != "insta/of":
|
||||
return None
|
||||
soft_row = row.get("softcore_row")
|
||||
hard_row = row.get("hardcore_row")
|
||||
if not isinstance(soft_row, dict) or not isinstance(hard_row, dict):
|
||||
return None
|
||||
|
||||
hard_row_for_text = dict(hard_row)
|
||||
options = row.get("options")
|
||||
if isinstance(options, dict) and options.get("continuity") == "same_creator_same_room":
|
||||
if soft_row.get("scene_text"):
|
||||
hard_row_for_text["scene_text"] = soft_row["scene_text"]
|
||||
if soft_row.get("composition"):
|
||||
hard_row_for_text["composition"] = soft_row["composition"]
|
||||
|
||||
soft_text, _soft_method = _metadata_to_prose(soft_row, detail_level, keep_style)
|
||||
hard_text, _hard_method = _metadata_to_prose(hard_row_for_text, detail_level, keep_style)
|
||||
descriptor = _clean_text(row.get("shared_descriptor"))
|
||||
options = row.get("options") if isinstance(row.get("options"), dict) else {}
|
||||
cast_descriptors = row.get("shared_cast_descriptors")
|
||||
if isinstance(cast_descriptors, list):
|
||||
cast_descriptor_text = "; ".join(_clean_text(item) for item in cast_descriptors if _clean_text(item))
|
||||
else:
|
||||
cast_descriptor_text = _clean_text(cast_descriptors)
|
||||
labels = _cast_labels(cast_descriptor_text)
|
||||
|
||||
same_soft_cast = options.get("softcore_cast") == "same_as_hardcore"
|
||||
|
||||
parts = []
|
||||
if cast_descriptor_text and same_soft_cast:
|
||||
parts.append(_natural_cast_descriptor_text(cast_descriptor_text))
|
||||
elif descriptor:
|
||||
parts.append(f"A {descriptor}")
|
||||
if cast_descriptor_text and not same_soft_cast:
|
||||
parts.append(_natural_cast_descriptor_text(cast_descriptor_text))
|
||||
if same_soft_cast:
|
||||
parts.append("The softcore version keeps the same adult cast present together in a non-explicit teaser setup")
|
||||
partner_styling = row.get("softcore_partner_styling")
|
||||
if isinstance(partner_styling, dict):
|
||||
outfits = partner_styling.get("outfits")
|
||||
if isinstance(outfits, list):
|
||||
outfit_text = _human_join([_clean_text(item) for item in outfits if _clean_text(item)])
|
||||
outfit_text = _natural_label_text(outfit_text, labels)
|
||||
if outfit_text:
|
||||
parts.append(f"Softcore partner styling: {outfit_text}")
|
||||
pose = _clean_text(partner_styling.get("pose"))
|
||||
if pose:
|
||||
parts.append(f"The shared softcore cast pose is {pose}")
|
||||
if soft_text:
|
||||
parts.append(f"Softcore version: {soft_text}")
|
||||
if hard_text:
|
||||
parts.append(f"Hardcore version: {hard_text}")
|
||||
if not parts:
|
||||
return None
|
||||
return _join_sentences(parts), "metadata(insta_of_pair)"
|
||||
def _insta_of_pair_from_row(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> tuple[str, str] | None:
|
||||
return caption_metadata_routes.insta_of_pair_from_row(
|
||||
_caption_metadata_route_request(row, detail_level, keep_style, target),
|
||||
_caption_metadata_route_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
def _metadata_to_prose(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str]:
|
||||
def _metadata_to_prose(
|
||||
row: dict[str, Any],
|
||||
detail_level: str,
|
||||
keep_style: bool,
|
||||
target: str = "auto",
|
||||
) -> tuple[str, str]:
|
||||
for builder in (
|
||||
_insta_of_pair_from_row,
|
||||
_configured_cast_from_row,
|
||||
@@ -630,10 +267,12 @@ def _metadata_to_prose(row: dict[str, Any], detail_level: str, keep_style: bool)
|
||||
_couple_from_row,
|
||||
_group_or_layout_from_row,
|
||||
):
|
||||
result = builder(row, detail_level, keep_style)
|
||||
result = builder(row, detail_level, keep_style, target)
|
||||
if result:
|
||||
return result
|
||||
return _text_to_prose(_clean_text(row.get("caption") or row.get("prompt")), detail_level, keep_style)
|
||||
prose, method = result
|
||||
return _append_formatter_hints(prose, row), method
|
||||
prose, method = _text_to_prose(_clean_text(row.get("caption") or row.get("prompt")), detail_level, keep_style)
|
||||
return _append_formatter_hints(prose, row), method
|
||||
|
||||
|
||||
def _prompt_to_prose(text: str, detail_level: str, keep_style: bool) -> tuple[str, str] | None:
|
||||
@@ -713,6 +352,23 @@ def _text_to_prose(text: str, detail_level: str, keep_style: bool) -> tuple[str,
|
||||
return prose or _sentence(text), "text(fallback)"
|
||||
|
||||
|
||||
def _caption_format_dependencies() -> caption_format_route.CaptionFormatDependencies:
|
||||
return caption_format_route.CaptionFormatDependencies(
|
||||
apply_caption_profile=lambda profile, detail, style, include: caption_policy.apply_caption_profile(
|
||||
profile,
|
||||
detail_level=detail,
|
||||
style_policy=style,
|
||||
include_trigger=include,
|
||||
),
|
||||
keep_style_terms=caption_policy.keep_style_terms,
|
||||
row_from_inputs=_row_from_inputs,
|
||||
metadata_to_prose=_metadata_to_prose,
|
||||
text_to_prose=_text_to_prose,
|
||||
with_trigger=_with_trigger,
|
||||
sanitize_prose_text=sanitize_prose_text,
|
||||
)
|
||||
|
||||
|
||||
def naturalize_caption(
|
||||
source_text: str,
|
||||
metadata_json: str = "",
|
||||
@@ -721,16 +377,50 @@ def naturalize_caption(
|
||||
include_trigger: bool = True,
|
||||
detail_level: str = "balanced",
|
||||
style_policy: str = "drop_style_tail",
|
||||
caption_profile: str = caption_policy.CAPTION_PROFILE_DEFAULT,
|
||||
target: str = "auto",
|
||||
) -> 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)
|
||||
caption = sanitize_prose_text(_with_trigger(prose, trigger, include_trigger), triggers=(trigger,))
|
||||
return caption, f"{row_method}:{method}"
|
||||
prose, method = _text_to_prose(source_text, detail_level, keep_style)
|
||||
caption = sanitize_prose_text(_with_trigger(prose, trigger, include_trigger), triggers=(trigger,))
|
||||
return caption, method
|
||||
return caption_format_route.naturalize_caption(
|
||||
caption_format_route.CaptionFormatRequest(
|
||||
source_text=source_text,
|
||||
metadata_json=metadata_json,
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
trigger=trigger,
|
||||
include_trigger=include_trigger,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
caption_profile=caption_profile,
|
||||
),
|
||||
_caption_format_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
def naturalize_caption_with_trace(
|
||||
source_text: str,
|
||||
metadata_json: str = "",
|
||||
input_hint: str = "auto",
|
||||
target: str = "auto",
|
||||
trigger: str = DEFAULT_TRIGGER,
|
||||
include_trigger: bool = True,
|
||||
detail_level: str = "balanced",
|
||||
style_policy: str = "drop_style_tail",
|
||||
caption_profile: str = caption_policy.CAPTION_PROFILE_DEFAULT,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Rewrite text like naturalize_caption and include formatter route trace JSON."""
|
||||
result = caption_format_route.naturalize_caption_result(
|
||||
caption_format_route.CaptionFormatRequest(
|
||||
source_text=source_text,
|
||||
metadata_json=metadata_json,
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
trigger=trigger,
|
||||
include_trigger=include_trigger,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
caption_profile=caption_profile,
|
||||
),
|
||||
_caption_format_dependencies(),
|
||||
)
|
||||
return result.as_trace_tuple()
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import formatter_detail as detail_policy
|
||||
from . import formatter_input as input_policy
|
||||
from . import route_metadata as route_metadata_policy
|
||||
except ImportError: # Allows local smoke tests with `python tools/prompt_smoke.py`.
|
||||
import formatter_detail as detail_policy
|
||||
import formatter_input as input_policy
|
||||
import route_metadata as route_metadata_policy
|
||||
|
||||
|
||||
OLD_TRIGGER = "sxcpinup_coloredpencil"
|
||||
DEFAULT_TRIGGER = "sxcppnl7"
|
||||
|
||||
DETAIL_LEVELS = detail_policy.DETAIL_LEVELS
|
||||
STYLE_POLICIES = ("drop_style_tail", "keep_style_terms")
|
||||
CAPTION_PROFILE_DEFAULT = "manual_controls"
|
||||
|
||||
CAPTION_PROFILES = {
|
||||
"manual_controls": {},
|
||||
"training_concise": {
|
||||
"detail_level": "concise",
|
||||
"style_policy": "drop_style_tail",
|
||||
"include_trigger": True,
|
||||
},
|
||||
"training_dense": {
|
||||
"detail_level": "dense",
|
||||
"style_policy": "drop_style_tail",
|
||||
"include_trigger": True,
|
||||
},
|
||||
"browsing": {
|
||||
"detail_level": "balanced",
|
||||
"style_policy": "keep_style_terms",
|
||||
"include_trigger": False,
|
||||
},
|
||||
}
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
ITEM_LABELS = (
|
||||
"Sexual pose",
|
||||
"Erotic outfit",
|
||||
"Clothing",
|
||||
)
|
||||
|
||||
ACTION_FAMILY_CAPTION_LABELS = {
|
||||
"anal": "anal action",
|
||||
"foreplay": "foreplay action",
|
||||
"manual": "manual action",
|
||||
"outercourse": "non-penetrative action",
|
||||
"oral": "oral action",
|
||||
"penetration": "penetrative action",
|
||||
"threesome": "three-person action",
|
||||
"group": "group action",
|
||||
"toy_double": "toy-assisted double-contact action",
|
||||
"climax": "climax action",
|
||||
}
|
||||
|
||||
POSITION_FAMILY_CAPTION_LABELS = {
|
||||
"penetrative": "penetrative action",
|
||||
"foreplay": "foreplay action",
|
||||
"interaction": "interaction beat",
|
||||
"manual": "manual action",
|
||||
"oral": "oral action",
|
||||
"outercourse": "non-penetrative action",
|
||||
"anal": "anal action",
|
||||
"climax": "climax action",
|
||||
"threesome": "three-person action",
|
||||
"group": "group action",
|
||||
}
|
||||
|
||||
|
||||
def normalize_detail_level(value: str) -> str:
|
||||
return detail_policy.normalize_detail_level(value)
|
||||
|
||||
|
||||
def _choice_key(value: Any) -> str:
|
||||
return str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
def normalize_style_policy(value: str) -> str:
|
||||
value = _choice_key(value)
|
||||
return value if value in STYLE_POLICIES else "drop_style_tail"
|
||||
|
||||
|
||||
def style_policy_choices() -> list[str]:
|
||||
return list(STYLE_POLICIES)
|
||||
|
||||
|
||||
def caption_profile_choices() -> list[str]:
|
||||
return list(CAPTION_PROFILES)
|
||||
|
||||
|
||||
def normalize_caption_profile(value: str) -> str:
|
||||
value = _choice_key(value)
|
||||
return value if value in CAPTION_PROFILES else CAPTION_PROFILE_DEFAULT
|
||||
|
||||
|
||||
def apply_caption_profile(
|
||||
caption_profile: str,
|
||||
*,
|
||||
detail_level: str,
|
||||
style_policy: str,
|
||||
include_trigger: bool,
|
||||
) -> tuple[str, str, bool]:
|
||||
profile = CAPTION_PROFILES[normalize_caption_profile(caption_profile)]
|
||||
return (
|
||||
normalize_detail_level(profile.get("detail_level", detail_level)),
|
||||
normalize_style_policy(profile.get("style_policy", style_policy)),
|
||||
bool(profile.get("include_trigger", include_trigger)),
|
||||
)
|
||||
|
||||
|
||||
def keep_style_terms(style_policy: str) -> bool:
|
||||
return normalize_style_policy(style_policy) == "keep_style_terms"
|
||||
|
||||
|
||||
def detail_allows(level: str, dense_only: bool = False) -> bool:
|
||||
return detail_policy.detail_allows(level, dense_only=dense_only)
|
||||
|
||||
|
||||
def strip_style_tail(text: str) -> str:
|
||||
text = input_policy.clean_text(text)
|
||||
for tail in STYLE_TAILS:
|
||||
if text.endswith(tail):
|
||||
return text[: -len(tail)].strip(" ,")
|
||||
return text
|
||||
|
||||
|
||||
def metadata_action_label(row: dict[str, Any], default: str = "sexual pose") -> str:
|
||||
position_family = route_metadata_policy.row_position_family(row)
|
||||
if position_family in POSITION_FAMILY_CAPTION_LABELS:
|
||||
return POSITION_FAMILY_CAPTION_LABELS[position_family]
|
||||
action_family = route_metadata_policy.row_action_family(row)
|
||||
if action_family in ACTION_FAMILY_CAPTION_LABELS:
|
||||
return ACTION_FAMILY_CAPTION_LABELS[action_family]
|
||||
return default
|
||||
|
||||
|
||||
def normalize_composition(text: str) -> str:
|
||||
text = re.sub(r"^vertical\s+", "", input_policy.clean_text(text), flags=re.IGNORECASE)
|
||||
text = re.sub(r"\s+composition$", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bcomposition\b", "frame", text, flags=re.IGNORECASE)
|
||||
return text.strip(" ,")
|
||||
|
||||
|
||||
def clean_clothing(text: str) -> str:
|
||||
text = input_policy.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(" ,")
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import caption_metadata_routes
|
||||
from . import caption_policy
|
||||
from . import formatter_input as input_policy
|
||||
from . import item_axis_policy
|
||||
from . import krea_cast as cast_policy
|
||||
from . import route_metadata as route_metadata_policy
|
||||
from . import softcore_text_policy
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
import caption_metadata_routes
|
||||
import caption_policy
|
||||
import formatter_input as input_policy
|
||||
import item_axis_policy
|
||||
import krea_cast as cast_policy
|
||||
import route_metadata as route_metadata_policy
|
||||
import softcore_text_policy
|
||||
|
||||
|
||||
OLD_TRIGGER = caption_policy.OLD_TRIGGER
|
||||
DEFAULT_TRIGGER = caption_policy.DEFAULT_TRIGGER
|
||||
PROMPT_FIELD_LABELS = input_policy.prompt_field_labels()
|
||||
ITEM_LABELS = caption_policy.ITEM_LABELS
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
return input_policy.clean_text(value)
|
||||
|
||||
|
||||
def is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def expression_disabled(row: dict[str, Any]) -> bool:
|
||||
return bool(row.get("expression_disabled")) or is_false(row.get("expression_enabled", True))
|
||||
|
||||
|
||||
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 formatter_hint_parts(row: dict[str, Any]) -> list[str]:
|
||||
hints: list[str] = []
|
||||
if not isinstance(row, dict):
|
||||
return hints
|
||||
for hint in route_metadata_policy.row_formatter_hints(row, "caption"):
|
||||
hint = clean_text(hint).strip(" .")
|
||||
if hint and hint not in hints:
|
||||
hints.append(hint)
|
||||
return hints
|
||||
|
||||
|
||||
def append_formatter_hints(prose: str, row: dict[str, Any]) -> str:
|
||||
hints = formatter_hint_parts(row)
|
||||
if not hints:
|
||||
return prose
|
||||
return join_sentences([prose, *hints])
|
||||
|
||||
|
||||
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 metadata_action_label(row: dict[str, Any], default: str = "sexual pose") -> str:
|
||||
return caption_policy.metadata_action_label(row, default)
|
||||
|
||||
|
||||
def item_axis_detail_text(row: dict[str, Any], existing_text: str = "") -> str:
|
||||
details = item_axis_policy.row_axis_value_texts(
|
||||
row,
|
||||
skip_keys=item_axis_policy.METADATA_AXIS_KEYS,
|
||||
existing_text=existing_text,
|
||||
)
|
||||
return human_join(details)
|
||||
|
||||
|
||||
def prompt_cast_descriptors(text: str) -> str:
|
||||
return cast_policy.prompt_cast_descriptors(text)
|
||||
|
||||
|
||||
def cast_entries(text: str) -> list[tuple[str, str]]:
|
||||
return cast_policy.cast_entries(text)
|
||||
|
||||
|
||||
def natural_cast_descriptor_text(text: str) -> str:
|
||||
return cast_policy.natural_cast_descriptor_text(text)
|
||||
|
||||
|
||||
def cast_labels(text: str) -> list[str]:
|
||||
return cast_policy.cast_labels(text)
|
||||
|
||||
|
||||
def natural_label_text(text: Any, labels: list[str]) -> str:
|
||||
return cast_policy.natural_label_text(text, labels, capitalize_sentence_starts=False)
|
||||
|
||||
|
||||
def strip_style_tail(text: str) -> str:
|
||||
return caption_policy.strip_style_tail(text)
|
||||
|
||||
|
||||
def remove_trigger(text: str, trigger: str) -> str:
|
||||
return input_policy.strip_trigger_prefix(
|
||||
text,
|
||||
(trigger, OLD_TRIGGER, DEFAULT_TRIGGER),
|
||||
remove_exact=True,
|
||||
)
|
||||
|
||||
|
||||
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 prompt_field(text: str, label: str) -> str:
|
||||
return input_policy.prompt_field(text, label, field_labels=PROMPT_FIELD_LABELS)
|
||||
|
||||
|
||||
def row_value(row: dict[str, Any], key: str, labels: tuple[str, ...] = ()) -> str:
|
||||
return input_policy.row_value(row, key, labels, field_labels=PROMPT_FIELD_LABELS)
|
||||
|
||||
|
||||
def field_row_value(row: dict[str, Any], key: str) -> str:
|
||||
return row_value(row, key)
|
||||
|
||||
|
||||
def field_from_any_prompt(text: str, labels: tuple[str, ...]) -> str:
|
||||
for label in labels:
|
||||
value = input_policy.prompt_field(text, label, field_labels=PROMPT_FIELD_LABELS)
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_composition(text: str) -> str:
|
||||
return caption_policy.normalize_composition(text)
|
||||
|
||||
|
||||
def clean_clothing(text: str) -> str:
|
||||
return caption_policy.clean_clothing(text)
|
||||
|
||||
|
||||
def body_phrase(body: Any, figure_note: Any = "") -> str:
|
||||
body = clean_text(body)
|
||||
figure_note = clean_text(figure_note)
|
||||
if not body:
|
||||
return figure_note
|
||||
if not figure_note:
|
||||
return f"{body} figure"
|
||||
if "figure" in figure_note.lower():
|
||||
return f"{body} build and {figure_note}"
|
||||
return f"{body} figure with {figure_note}"
|
||||
|
||||
|
||||
def 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"))
|
||||
phrase = clean_text(row.get("body_phrase"))
|
||||
if not phrase:
|
||||
body = clean_text(row.get("body_type") or row.get("body"))
|
||||
figure = clean_text(row.get("figure"))
|
||||
phrase = body_phrase(body, figure)
|
||||
front = f"{subject}, {age}, {phrase}, "
|
||||
if subject in ("woman", "man") and age and 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, phrase, skin, hair, eyes, _rest = pieces
|
||||
if subject not in ("woman", "man"):
|
||||
return {}
|
||||
return {
|
||||
"caption_subject": subject,
|
||||
"caption_age": age,
|
||||
"caption_body_phrase": 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:
|
||||
return caption_policy.detail_allows(level, dense_only=dense_only)
|
||||
|
||||
|
||||
def metadata_route_dependencies(
|
||||
metadata_to_prose: Callable[..., tuple[str, str]],
|
||||
) -> caption_metadata_routes.CaptionMetadataRouteDependencies:
|
||||
return caption_metadata_routes.CaptionMetadataRouteDependencies(
|
||||
item_labels=ITEM_LABELS,
|
||||
clean_text=clean_text,
|
||||
row_value=row_value,
|
||||
field_row_value=field_row_value,
|
||||
clean_clothing=clean_clothing,
|
||||
normalize_composition=normalize_composition,
|
||||
expression_disabled=expression_disabled,
|
||||
detail_allows=detail_allows,
|
||||
join_sentences=join_sentences,
|
||||
human_join=human_join,
|
||||
article=article,
|
||||
cap_first=cap_first,
|
||||
body_phrase=body_phrase,
|
||||
single_caption_front=single_caption_front,
|
||||
pose_clause=pose_clause,
|
||||
age_subject=age_subject,
|
||||
clean_age_phrase=clean_age_phrase,
|
||||
subject_phrase_from_counts=subject_phrase_from_counts,
|
||||
verb_for_row=verb_for_row,
|
||||
metadata_action_label=metadata_action_label,
|
||||
item_axis_detail_text=item_axis_detail_text,
|
||||
natural_cast_descriptor_text=natural_cast_descriptor_text,
|
||||
cast_labels=cast_labels,
|
||||
natural_label_text=natural_label_text,
|
||||
softcore_caption_setup_phrase=softcore_text_policy.softcore_caption_setup_phrase,
|
||||
metadata_to_prose=metadata_to_prose,
|
||||
)
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import character_config as character_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import character_config as character_policy
|
||||
|
||||
|
||||
Choose = Callable[[Any, list[tuple[str, str, str]]], tuple[str, str, str]]
|
||||
|
||||
|
||||
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 cast_summary_phrase(women_count: int, men_count: int) -> str:
|
||||
women_count = max(0, int(women_count))
|
||||
men_count = max(0, int(men_count))
|
||||
if women_count + men_count == 0:
|
||||
women_count = 1
|
||||
person_count = women_count + men_count
|
||||
women_label = "woman" if women_count == 1 else "women"
|
||||
men_label = "man" if men_count == 1 else "men"
|
||||
return f"{women_count} {women_label}, {men_count} {men_label}, {person_count} total adults"
|
||||
|
||||
|
||||
def explicit_character_slot_label(slot: dict[str, Any]) -> str:
|
||||
label = str(slot.get("label") or "").strip().upper()
|
||||
if label in character_policy.CHARACTER_LABEL_CHOICES and label != "AUTO_CHAIN":
|
||||
return label
|
||||
return ""
|
||||
|
||||
|
||||
def character_slot_label_map(slots: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
label_map: dict[str, dict[str, Any]] = {}
|
||||
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
for subject_type, prefix in (("woman", "Woman"), ("man", "Man")):
|
||||
subject_slots = [slot for slot in slots if slot.get("subject_type") == subject_type]
|
||||
auto_slots = [slot for slot in subject_slots if not explicit_character_slot_label(slot)]
|
||||
for index, slot in enumerate(reversed(auto_slots)):
|
||||
if index >= len(letters):
|
||||
break
|
||||
label_map[f"{prefix} {letters[index]}"] = slot
|
||||
for slot in subject_slots:
|
||||
explicit = explicit_character_slot_label(slot)
|
||||
if explicit:
|
||||
label_map[f"{prefix} {explicit}"] = slot
|
||||
return label_map
|
||||
|
||||
|
||||
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"))
|
||||
subject_phrase = parts[0] if len(parts) == 1 else 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"
|
||||
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_phrase(women_count, men_count),
|
||||
"scene_kind": scene_kind,
|
||||
}
|
||||
|
||||
|
||||
def couple_type_from_counts(
|
||||
rng: Any,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
*,
|
||||
choose: Choose,
|
||||
couple_types: list[tuple[str, str, str]],
|
||||
) -> tuple[str, str, str, int, int]:
|
||||
women_count = max(0, int(women_count))
|
||||
men_count = max(0, int(men_count))
|
||||
if women_count >= 2 and men_count == 0:
|
||||
return "two women", "two women", "close affectionate couple pose", 2, 0
|
||||
if men_count >= 2 and women_count == 0:
|
||||
return "two men", "two men", "relaxed romantic couple pose", 0, 2
|
||||
if women_count >= 1 and men_count >= 1:
|
||||
return "woman and man", "a woman and a man", "playful date-night pose", 1, 1
|
||||
|
||||
primary_subject, subject_phrase, pose = choose(rng, couple_types)
|
||||
if primary_subject == "two women":
|
||||
return primary_subject, subject_phrase, pose, 2, 0
|
||||
if primary_subject == "two men":
|
||||
return primary_subject, subject_phrase, pose, 0, 2
|
||||
return primary_subject, subject_phrase, pose, 1, 1
|
||||
@@ -92,6 +92,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.75,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "foreplay"
|
||||
},
|
||||
"item_label": "Foreplay action",
|
||||
"positive_suffix": "Use clear adult body contact, readable hands and faces, visible undressing, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Foreplay action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult body contact, kissing, caressing, undressing, visible arousal, exposed skin, and readable hand placement. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -102,7 +106,7 @@
|
||||
"item_templates": [
|
||||
"{tease_act} in {position}, with {touch_detail}, {clothing_detail}, and {mood_detail}",
|
||||
"{position} featuring {tease_act}, {body_contact}, {touch_detail}, and {visibility}",
|
||||
"hardcore foreplay setup: {tease_act}, {clothing_detail}, {face_detail}, and {body_contact}",
|
||||
"hardcore foreplay setup with {tease_act}, {clothing_detail}, {face_detail}, and {body_contact}",
|
||||
"{tease_act} on {surface}, with {touch_detail}, {mood_detail}, and {visibility}",
|
||||
"{position} while {tease_act}, with {face_detail}, {clothing_detail}, and {touch_detail}"
|
||||
],
|
||||
@@ -211,6 +215,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.85,
|
||||
"item_template_metadata": {
|
||||
"action_family": "manual",
|
||||
"position_family": "manual"
|
||||
},
|
||||
"item_label": "Manual action",
|
||||
"positive_suffix": "Use clear adult manual contact, readable hands, explicit body positioning, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Manual action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult manual stimulation, visible hands, exposed skin, clear body positioning, and readable reaction. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -222,7 +230,7 @@
|
||||
"{manual_act} in {position}, with {manual_detail}, {body_contact}, and {visibility}",
|
||||
"{position} featuring {manual_act}, {hand_detail}, {reaction_detail}, and {visibility}",
|
||||
"{manual_act} on {surface}, with {manual_detail}, {hand_detail}, and {reaction_detail}",
|
||||
"manual stimulation setup: {position}, {manual_act}, {body_contact}, and {visibility}",
|
||||
"manual stimulation setup with {position}, {manual_act}, {body_contact}, and {visibility}",
|
||||
"{position} while {manual_act}, with {hand_detail}, {manual_detail}, and {reaction_detail}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -316,6 +324,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.7,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "interaction"
|
||||
},
|
||||
"item_label": "Body interaction",
|
||||
"positive_suffix": "Use readable adult body contact, hands and mouth on skin, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Body interaction: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult body worship, close skin contact, mouth and hand placement, exposed skin, and readable body positioning. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -327,7 +339,7 @@
|
||||
"{worship_act} in {position}, with {touch_detail}, {face_detail}, and {visibility}",
|
||||
"{position} featuring {worship_act}, {body_contact}, {touch_detail}, and {reaction_detail}",
|
||||
"{worship_act} on {surface}, with {body_contact}, {face_detail}, and {visibility}",
|
||||
"body worship setup: {position}, {worship_act}, {touch_detail}, and {reaction_detail}",
|
||||
"body worship setup with {position}, {worship_act}, {touch_detail}, and {reaction_detail}",
|
||||
"{position} while {worship_act}, with {body_contact}, {face_detail}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -425,6 +437,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.65,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "interaction"
|
||||
},
|
||||
"item_label": "Transition action",
|
||||
"positive_suffix": "Use readable adult movement, clothing being moved, hands guiding bodies, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Transition action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult undressing, position changes, visible hands, exposed skin, and clear movement from one sexual beat to the next. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -436,7 +452,7 @@
|
||||
"{transition_act} in {position}, with {clothing_detail}, {hand_detail}, and {visibility}",
|
||||
"{position} featuring {transition_act}, {body_contact}, {clothing_detail}, and {movement_detail}",
|
||||
"{transition_act} on {surface}, with {hand_detail}, {body_contact}, and {visibility}",
|
||||
"position transition: {position}, {transition_act}, {movement_detail}, and {clothing_detail}",
|
||||
"position transition with {position}, {transition_act}, {movement_detail}, and {clothing_detail}",
|
||||
"{position} while {transition_act}, with {hand_detail}, {body_contact}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -530,6 +546,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.55,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "interaction"
|
||||
},
|
||||
"item_label": "Guidance action",
|
||||
"positive_suffix": "Use consensual adult control, readable hand placement, clear body positioning, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Guidance action: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through consensual adult guidance, hair or wrist control, body positioning, visible hands, exposed skin, and clear power dynamic. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -541,7 +561,7 @@
|
||||
"{control_act} in {position}, with {hand_detail}, {body_contact}, and {visibility}",
|
||||
"{position} featuring {control_act}, {power_detail}, {hand_detail}, and {reaction_detail}",
|
||||
"{control_act} on {surface}, with {body_contact}, {power_detail}, and {visibility}",
|
||||
"consensual control setup: {position}, {control_act}, {hand_detail}, and {reaction_detail}",
|
||||
"consensual control setup with {position}, {control_act}, {hand_detail}, and {reaction_detail}",
|
||||
"{position} while {control_act}, with {power_detail}, {body_contact}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -640,6 +660,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.6,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "interaction"
|
||||
},
|
||||
"item_label": "Camera performance",
|
||||
"positive_suffix": "Use creator-shot adult presentation, readable camera-facing pose, exposed skin, clear hand placement, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Camera performance: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through camera-aware adult presentation, body opened or displayed to the viewer, visible hands, exposed skin, and clean creator-shot framing. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -651,7 +675,7 @@
|
||||
"{performance_act} in {position}, with {presentation_detail}, {hand_detail}, and {visibility}",
|
||||
"{position} featuring {performance_act}, {camera_detail}, {presentation_detail}, and {reaction_detail}",
|
||||
"{performance_act} on {surface}, with {hand_detail}, {camera_detail}, and {visibility}",
|
||||
"creator-performance setup: {position}, {performance_act}, {presentation_detail}, and {reaction_detail}",
|
||||
"creator-performance setup with {position}, {performance_act}, {presentation_detail}, and {reaction_detail}",
|
||||
"{position} while {performance_act}, with {camera_detail}, {hand_detail}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -744,6 +768,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.55,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "interaction"
|
||||
},
|
||||
"item_label": "Group interaction",
|
||||
"positive_suffix": "Use readable adult group coordination, clear body spacing, visible watching/touching roles, exposed skin, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Group interaction: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult group coordination, watching, guiding hands, body presentation, exposed skin, and clear role spacing. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -755,7 +783,7 @@
|
||||
"{coordination_act} with {arrangement}, {touch_detail}, {reaction_detail}, and {visibility}",
|
||||
"{arrangement} featuring {coordination_act}, {body_contact}, {watching_detail}, and {visibility}",
|
||||
"{coordination_act} on {surface}, with {touch_detail}, {watching_detail}, and {body_contact}",
|
||||
"group coordination setup: {arrangement}, {coordination_act}, {watching_detail}, and {visibility}",
|
||||
"group coordination setup with {arrangement}, {coordination_act}, {watching_detail}, and {visibility}",
|
||||
"{arrangement} while {coordination_act}, with {touch_detail}, {reaction_detail}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -846,6 +874,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 0.35,
|
||||
"item_template_metadata": {
|
||||
"action_family": "foreplay",
|
||||
"position_family": "interaction"
|
||||
},
|
||||
"item_label": "Aftermath interaction",
|
||||
"positive_suffix": "Use adult post-sex intimacy, readable bodies and hands, visible aftermath details, warm erotic lighting, crisp comic linework, detailed hatching, and tactile textured paper.",
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Aftermath interaction: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit through adult post-sex closeness, cleanup, visible skin, relaxed body contact, aftermath details, and readable hands and faces. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
@@ -857,7 +889,7 @@
|
||||
"{aftercare_act} in {position}, with {cleanup_detail}, {body_contact}, and {visibility}",
|
||||
"{position} featuring {aftercare_act}, {touch_detail}, {cleanup_detail}, and {reaction_detail}",
|
||||
"{aftercare_act} on {surface}, with {body_contact}, {touch_detail}, and {visibility}",
|
||||
"post-sex aftermath setup: {position}, {aftercare_act}, {cleanup_detail}, and {reaction_detail}",
|
||||
"post-sex aftermath setup with {position}, {aftercare_act}, {cleanup_detail}, and {reaction_detail}",
|
||||
"{position} while {aftercare_act}, with {touch_detail}, {body_contact}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
@@ -950,11 +982,19 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "penetration",
|
||||
"position_family": "penetrative"
|
||||
},
|
||||
"scene_pools": ["hardcore_penetrative_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_penetration_expressions"],
|
||||
"composition_pools": ["penetration_compositions"],
|
||||
"item_templates": [
|
||||
"{penetration_act} in {position}, with {body_contact}, {intensity}, and {visibility}",
|
||||
{
|
||||
"template": "{penetration_act} in {position}, with {body_contact}, {intensity}, and {visibility}",
|
||||
"action_family": "penetration",
|
||||
"position_family": "penetrative"
|
||||
},
|
||||
"{position} while {penetration_act}, {hand_detail}, {mouth_detail}, and {visibility}",
|
||||
"{penetration_act} from {angle}, with {leg_detail}, {body_contact}, and {intensity}",
|
||||
"hardcore {position} featuring {penetration_act}, {thrust_detail}, {hand_detail}, and {visibility}",
|
||||
@@ -1119,18 +1159,26 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "oral",
|
||||
"position_family": "oral"
|
||||
},
|
||||
"scene_pools": ["hardcore_oral_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_oral_expressions"],
|
||||
"composition_pools": ["oral_compositions"],
|
||||
"item_templates": [
|
||||
"{oral_act} in {position}, with {hand_detail}, {expression_detail}, and {visibility}",
|
||||
{
|
||||
"template": "{oral_act} in {position}, with {hand_detail}, {expression_detail}, and {visibility}",
|
||||
"action_family": "oral",
|
||||
"position_family": "oral"
|
||||
},
|
||||
"{position} featuring {oral_act}, {body_contact}, {saliva_detail}, and {climax_hint}",
|
||||
"{oral_act} from {angle}, with {mouth_detail}, {hand_detail}, and {visibility}",
|
||||
"hardcore oral scene with {oral_act}, {body_contact}, {saliva_detail}, and {expression_detail}",
|
||||
"{oral_act} on {surface}, {hand_detail}, {mouth_detail}, and {climax_hint}",
|
||||
"{angle} view of {oral_act}, with {visibility}, {body_contact}, and {expression_detail}",
|
||||
"{position} while {oral_act}, with {saliva_detail}, {hand_detail}, and {climax_hint}",
|
||||
"explicit mouth-to-genitals pose: {oral_act}, {mouth_detail}, {body_contact}, and {visibility}"
|
||||
"explicit mouth-to-genitals pose with {oral_act}, {mouth_detail}, {body_contact}, and {visibility}"
|
||||
],
|
||||
"item_axes": {
|
||||
"angle": [
|
||||
@@ -1258,6 +1306,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "outercourse",
|
||||
"position_family": "outercourse"
|
||||
},
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_outercourse_expressions"],
|
||||
"compositions": [
|
||||
@@ -1271,10 +1323,14 @@
|
||||
{"text": "close candid creator-shot frame centered on non-penetrative genital contact", "min_people": 2, "max_people": 3}
|
||||
],
|
||||
"item_templates": [
|
||||
"{outer_act} in {position}, with {contact_detail}, {hand_detail}, and {visibility}",
|
||||
{
|
||||
"template": "{outer_act} in {position}, with {contact_detail}, {hand_detail}, and {visibility}",
|
||||
"action_family": "outercourse",
|
||||
"position_family": "outercourse"
|
||||
},
|
||||
"{position} featuring {outer_act}, {body_contact}, {texture_detail}, seen from a {angle} view",
|
||||
"{angle} view of {outer_act}, with {visibility}, {contact_detail}, and {expression_detail}",
|
||||
"explicit non-penetrative sex pose: {outer_act}, {position}, {contact_detail}, and {visibility}",
|
||||
"explicit non-penetrative sex pose with {outer_act}, {position}, {contact_detail}, and {visibility}",
|
||||
"{outer_act} on {surface}, with {hand_detail}, {body_contact}, and {texture_detail}",
|
||||
"{position} while {outer_act}, with {texture_detail}, {hand_detail}, and {visibility}"
|
||||
],
|
||||
@@ -1408,6 +1464,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "default",
|
||||
"position_family": "anal"
|
||||
},
|
||||
"scene_pools": ["hardcore_anal_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_anal_dp_expressions"],
|
||||
"composition_pools": ["anal_dp_compositions"],
|
||||
@@ -1419,7 +1479,7 @@
|
||||
"{double_act} on {surface}, with {leg_detail}, {intensity}, and {climax_hint}",
|
||||
"{angle} view of {double_act}, {body_arrangement}, {mouth_detail}, and {visibility}",
|
||||
"{anal_act} with {thrust_detail}, {hand_detail}, {body_contact}, and {climax_hint}",
|
||||
"explicit double-contact sex pose: {double_act}, {leg_detail}, {visibility}, and {intensity}"
|
||||
"explicit double-contact sex pose with {double_act}, {leg_detail}, {visibility}, and {intensity}"
|
||||
],
|
||||
"item_axes": {
|
||||
"anal_act": [
|
||||
@@ -1627,6 +1687,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "default",
|
||||
"position_family": "threesome"
|
||||
},
|
||||
"scene_pools": ["hardcore_threesome_scenes", "hardcore_group_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_group_expressions"],
|
||||
"composition_pools": ["threesome_compositions"],
|
||||
@@ -1634,7 +1698,7 @@
|
||||
"{threesome_act} with {body_arrangement}, {oral_detail}, {penetration_detail}, and {visibility}",
|
||||
"{body_arrangement} while {threesome_act}, with {hand_detail}, {mouth_detail}, and {climax_hint}",
|
||||
"{angle} threesome view featuring {threesome_act}, {body_contact}, {penetration_detail}, and {visibility}",
|
||||
"hardcore threesome pose: {threesome_act}, {body_arrangement}, {oral_detail}, and {climax_hint}",
|
||||
"hardcore threesome pose with {threesome_act}, {body_arrangement}, {oral_detail}, and {climax_hint}",
|
||||
"{threesome_act} on {surface}, with {hand_detail}, {body_contact}, and {visibility}",
|
||||
"{angle} view of {body_arrangement}, {penetration_detail}, {mouth_detail}, and {intensity}",
|
||||
"three-body explicit sex pose with {threesome_act}, {oral_detail}, {hand_detail}, and {visibility}",
|
||||
@@ -1810,6 +1874,10 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "default",
|
||||
"position_family": "group"
|
||||
},
|
||||
"scene_pools": ["hardcore_group_scenes"],
|
||||
"expression_pools": ["hardcore_group_expressions"],
|
||||
"composition_pools": ["group_sex_compositions"],
|
||||
@@ -1817,7 +1885,7 @@
|
||||
"{group_act} with {arrangement}, {contact_detail}, {fluid_detail}, and {visibility}",
|
||||
"{arrangement} featuring {group_act}, {oral_detail}, {penetration_detail}, and {intensity}",
|
||||
"{angle} group-sex view with {group_act}, {contact_detail}, {climax_detail}, and {visibility}",
|
||||
"hardcore orgy pose: {arrangement}, {group_act}, {oral_detail}, and {fluid_detail}",
|
||||
"hardcore orgy pose with {arrangement}, {group_act}, {oral_detail}, and {fluid_detail}",
|
||||
"{group_act} on {surface}, with {penetration_detail}, {contact_detail}, and {visibility}",
|
||||
"{angle} view of {arrangement}, {fluid_detail}, {intensity}, and {climax_detail}",
|
||||
"explicit adult group pile with {group_act}, {oral_detail}, {penetration_detail}, and {visibility}",
|
||||
@@ -1982,11 +2050,19 @@
|
||||
"inherit_expressions": false,
|
||||
"inherit_compositions": false,
|
||||
"weight": 1.0,
|
||||
"item_template_metadata": {
|
||||
"action_family": "climax",
|
||||
"position_family": "climax"
|
||||
},
|
||||
"scene_pools": ["hardcore_climax_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_climax_expressions"],
|
||||
"composition_pools": ["climax_compositions"],
|
||||
"item_templates": [
|
||||
"{climax_act} with {fluid_location}, {body_position}, {expression_detail}, and {visibility}",
|
||||
{
|
||||
"template": "{climax_act} with {fluid_location}, {body_position}, {expression_detail}, and {visibility}",
|
||||
"action_family": "climax",
|
||||
"position_family": "climax"
|
||||
},
|
||||
"{body_position} during {climax_act}, with {hand_detail}, {fluid_location}, and {fluid_detail}",
|
||||
"{angle} aftermath view with {body_position}, {body_contact}, and {visibility}",
|
||||
"hardcore post-ejaculation scene with {fluid_location}, {body_position}, {expression_detail}, and {visibility}",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
RANDOM_SUBCATEGORY = "random"
|
||||
|
||||
CATEGORY_PRESETS = {
|
||||
"auto_weighted": ("auto_weighted", RANDOM_SUBCATEGORY),
|
||||
"auto_full": ("auto_full", RANDOM_SUBCATEGORY),
|
||||
"woman": ("woman", RANDOM_SUBCATEGORY),
|
||||
"man": ("man", RANDOM_SUBCATEGORY),
|
||||
"couple": ("couple", RANDOM_SUBCATEGORY),
|
||||
"group_or_layout": ("group_or_layout", RANDOM_SUBCATEGORY),
|
||||
"women_casual": ("Casual clothes", RANDOM_SUBCATEGORY),
|
||||
"men_casual": ("Men casual clothes", RANDOM_SUBCATEGORY),
|
||||
"couple_casual": ("Couple casual clothes", RANDOM_SUBCATEGORY),
|
||||
"provocative_erotic": ("Provocative erotic clothes", RANDOM_SUBCATEGORY),
|
||||
"hardcore_pose": ("Hardcore sexual poses", RANDOM_SUBCATEGORY),
|
||||
"custom_random": ("custom_random", RANDOM_SUBCATEGORY),
|
||||
}
|
||||
|
||||
CAST_PRESETS = {
|
||||
"solo_woman": (1, 0),
|
||||
"solo_man": (0, 1),
|
||||
"mixed_couple": (1, 1),
|
||||
"two_women": (2, 0),
|
||||
"two_men": (0, 2),
|
||||
"threesome_2w1m": (2, 1),
|
||||
"small_group_3w2m": (3, 2),
|
||||
}
|
||||
|
||||
|
||||
def category_preset_choices() -> list[str]:
|
||||
return list(CATEGORY_PRESETS)
|
||||
|
||||
|
||||
def cast_preset_choices() -> list[str]:
|
||||
return list(CAST_PRESETS) + ["custom_counts"]
|
||||
|
||||
|
||||
def build_category_config_json(preset: str = "auto_weighted", subcategory: str = RANDOM_SUBCATEGORY) -> str:
|
||||
category, default_subcategory = CATEGORY_PRESETS.get(preset, CATEGORY_PRESETS["auto_weighted"])
|
||||
chosen_subcategory = subcategory if subcategory and subcategory != RANDOM_SUBCATEGORY else default_subcategory
|
||||
return json.dumps(
|
||||
{
|
||||
"preset": preset if preset in CATEGORY_PRESETS else "auto_weighted",
|
||||
"category": category,
|
||||
"subcategory": chosen_subcategory,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def parse_category_config(category_config: str | dict[str, Any] | None) -> tuple[str, str]:
|
||||
if not category_config:
|
||||
return CATEGORY_PRESETS["auto_weighted"]
|
||||
if isinstance(category_config, dict):
|
||||
raw = category_config
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(category_config))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid category_config JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("category_config must be a JSON object")
|
||||
preset = str(raw.get("preset") or "auto_weighted")
|
||||
category, subcategory = CATEGORY_PRESETS.get(preset, CATEGORY_PRESETS["auto_weighted"])
|
||||
category = str(raw.get("category") or category)
|
||||
subcategory = str(raw.get("subcategory") or subcategory or RANDOM_SUBCATEGORY)
|
||||
return category, subcategory
|
||||
|
||||
|
||||
def build_cast_config_json(cast_mode: str = "mixed_couple", women_count: int = 1, men_count: int = 1) -> str:
|
||||
if cast_mode in CAST_PRESETS:
|
||||
women_count, men_count = CAST_PRESETS[cast_mode]
|
||||
else:
|
||||
women_count = max(0, min(12, int(women_count)))
|
||||
men_count = max(0, min(12, int(men_count)))
|
||||
if women_count + men_count == 0:
|
||||
women_count = 1
|
||||
cast_mode = "custom_counts"
|
||||
return json.dumps(
|
||||
{
|
||||
"cast_mode": cast_mode,
|
||||
"women_count": int(women_count),
|
||||
"men_count": int(men_count),
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def parse_cast_config(cast_config: str | dict[str, Any] | None) -> dict[str, int | str]:
|
||||
if not cast_config:
|
||||
return {"cast_mode": "mixed_couple", "women_count": 1, "men_count": 1}
|
||||
if isinstance(cast_config, dict):
|
||||
raw = cast_config
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(cast_config))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid cast_config JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("cast_config must be a JSON object")
|
||||
return json.loads(
|
||||
build_cast_config_json(
|
||||
str(raw.get("cast_mode") or "custom_counts"),
|
||||
raw.get("women_count", 1),
|
||||
raw.get("men_count", 1),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_parse_category_config = parse_category_config
|
||||
_parse_cast_config = parse_cast_config
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import generate_prompt_batches as g
|
||||
from . import row_item as row_item_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import category_library as category_policy
|
||||
import generate_prompt_batches as g
|
||||
import row_item as row_item_policy
|
||||
|
||||
|
||||
BUILTIN_CATEGORIES = [
|
||||
"auto_weighted",
|
||||
"auto_full",
|
||||
"woman",
|
||||
"man",
|
||||
"couple",
|
||||
"group_or_layout",
|
||||
"custom_random",
|
||||
]
|
||||
|
||||
_EXTENSIONS_APPLIED = False
|
||||
|
||||
|
||||
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 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 category_policy.category_json_files():
|
||||
data = category_policy.read_category_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 = (
|
||||
[row_item_policy.pair_from(item) for item in list_from(additions)]
|
||||
if expects_pair
|
||||
else [row_item_policy.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 category_policy.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 = [category_policy.RANDOM_SUBCATEGORY]
|
||||
for category in category_policy.load_category_library():
|
||||
for subcategory in category["subcategories"]:
|
||||
choices.append(category_policy.exact_subcategory_selector(category, subcategory))
|
||||
return choices
|
||||
@@ -0,0 +1,574 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
CATEGORY_DIR = ROOT_DIR / "categories"
|
||||
RANDOM_SUBCATEGORY = "random"
|
||||
|
||||
|
||||
def category_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_category_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:
|
||||
text = str(value or "").lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "_", text)
|
||||
return text.strip("_")[:48] or "custom"
|
||||
|
||||
|
||||
def _list_from(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def _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 _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 _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 template_list(category: dict[str, Any], subcategory: dict[str, Any], item: Any, key: str) -> list[Any]:
|
||||
if isinstance(item, dict) and key in item:
|
||||
return _list_from(item[key])
|
||||
if key in subcategory:
|
||||
return _list_from(subcategory[key])
|
||||
if key in category:
|
||||
return _list_from(category[key])
|
||||
return []
|
||||
|
||||
|
||||
def _constraint_int(entry: dict[str, Any], key: str) -> int | None:
|
||||
if key not in entry:
|
||||
return None
|
||||
try:
|
||||
return int(entry[key])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _cast_requirement_matches(requirement: str, women_count: int, men_count: int) -> bool:
|
||||
total = women_count + men_count
|
||||
requirement = requirement.strip().lower()
|
||||
if requirement in ("", "any"):
|
||||
return True
|
||||
if requirement == "women_only":
|
||||
return women_count > 0 and men_count == 0
|
||||
if requirement == "men_only":
|
||||
return men_count > 0 and women_count == 0
|
||||
if requirement == "mixed":
|
||||
return women_count > 0 and men_count > 0
|
||||
if requirement == "has_women":
|
||||
return women_count > 0
|
||||
if requirement == "has_men":
|
||||
return men_count > 0
|
||||
if requirement == "solo":
|
||||
return total == 1
|
||||
if requirement == "couple":
|
||||
return total == 2
|
||||
if requirement == "threesome":
|
||||
return total == 3
|
||||
if requirement == "group":
|
||||
return total >= 4
|
||||
return True
|
||||
|
||||
|
||||
def _is_toy_assisted_double_couple_text(text: str) -> bool:
|
||||
text = text.lower()
|
||||
if "toy" not in text:
|
||||
return False
|
||||
return any(
|
||||
token in text
|
||||
for token in (
|
||||
"double penetration",
|
||||
"double-penetration",
|
||||
"vaginal and anal penetration",
|
||||
"second penetration point",
|
||||
"second point of contact",
|
||||
"second contact",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _heuristic_cast_compatible(text: str, women_count: int, men_count: int) -> bool:
|
||||
text = text.lower()
|
||||
if not text:
|
||||
return True
|
||||
total = women_count + men_count
|
||||
if total == 2 and women_count == 1 and men_count == 1:
|
||||
if "{double_act}" in text:
|
||||
return False
|
||||
if _is_toy_assisted_double_couple_text(text):
|
||||
return False
|
||||
if total == 1:
|
||||
solo_blocked_terms = (
|
||||
"partner",
|
||||
"partners",
|
||||
"two bodies",
|
||||
"three bodies",
|
||||
"bodies still pressed",
|
||||
"bodies pressed",
|
||||
"bodies tangled",
|
||||
"wet bodies",
|
||||
"chests heaving together",
|
||||
"straddling a partner",
|
||||
"shared climax",
|
||||
"between two",
|
||||
"from both sides",
|
||||
"front-and-back",
|
||||
"body contact",
|
||||
)
|
||||
if any(term in text for term in solo_blocked_terms):
|
||||
return False
|
||||
solo_toy_terms = ("toy", "dildo", "finger", "fingers", "self")
|
||||
if "penetration" in text and not any(term in text for term in solo_toy_terms):
|
||||
return False
|
||||
if total < 3 and "threesome" in text:
|
||||
return False
|
||||
if total != 3 and ("centered threesome" in text or "three-way" in text):
|
||||
return False
|
||||
if total < 3 and ("three bodies" in text or "center partner" in text or "center body" in text):
|
||||
return False
|
||||
if total < 4 and ("orgy" in text or "group sex" in text or "group-sex" in text or "group pile" in text):
|
||||
return False
|
||||
if total < 3 and (
|
||||
"double penetration" in text
|
||||
or "two partners penetrating" in text
|
||||
or "front-and-back penetration" in text
|
||||
or "one penis in pussy and one penis in ass" in text
|
||||
or "pussy and ass filled" in text
|
||||
or "vaginal and anal penetration at the same time" in text
|
||||
or "front-and-back double penetration" in text
|
||||
or "hardcore double penetration" in text
|
||||
or "kneeling double penetration" in text
|
||||
or "standing supported double penetration" in text
|
||||
or "deep double penetration" in text
|
||||
or "between two partners" in text
|
||||
or "from both sides" in text
|
||||
):
|
||||
toy_terms = ("strap-on", "strap on", "dildo", "toy", "finger")
|
||||
if not any(term in text for term in toy_terms):
|
||||
return False
|
||||
if men_count == 0:
|
||||
toy_terms = ("strap-on", "strap on", "dildo", "toy", "finger", "fingers")
|
||||
penetration_terms = (
|
||||
"vaginal penetration",
|
||||
"deep vaginal sex",
|
||||
"penetrative sex",
|
||||
"pussy penetration",
|
||||
"pussy stretched",
|
||||
"vaginal thrusting",
|
||||
"full-body penetrative",
|
||||
"close-contact vaginal",
|
||||
"penetration clearly visible",
|
||||
"explicit penetrative contact",
|
||||
)
|
||||
if any(term in text for term in penetration_terms) and not any(term in text for term in toy_terms):
|
||||
return False
|
||||
male_terms = (
|
||||
" penis",
|
||||
"penis ",
|
||||
"penises",
|
||||
"cum",
|
||||
"creampie",
|
||||
"facial",
|
||||
"blowjob",
|
||||
"fellatio",
|
||||
"deepthroat",
|
||||
"ejaculation",
|
||||
"semen",
|
||||
)
|
||||
if any(term in text for term in male_terms) and not any(term in text for term in toy_terms):
|
||||
return False
|
||||
elif men_count < 2 and "penises" in text:
|
||||
return False
|
||||
if women_count == 0:
|
||||
if "penetrative sex" in text and not any(term in text for term in ("anal", "ass", "male/male", "men")):
|
||||
return False
|
||||
female_terms = (
|
||||
"pussy",
|
||||
"vaginal",
|
||||
"vagina",
|
||||
"cunnilingus",
|
||||
"clit",
|
||||
"clitoris",
|
||||
"breasts",
|
||||
"breast ",
|
||||
"nipples",
|
||||
"nipple",
|
||||
"underboob",
|
||||
)
|
||||
if any(term in text for term in female_terms):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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 _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 category_json_files():
|
||||
data = read_category_json(path)
|
||||
categories.extend(_normalize_categories(data.get("categories", [])))
|
||||
return categories
|
||||
|
||||
|
||||
def load_named_pool_library(key: str) -> dict[str, list[Any]]:
|
||||
pools: dict[str, list[Any]] = {}
|
||||
for path in category_json_files():
|
||||
data = read_category_json(path)
|
||||
raw_pools = data.get(key, {})
|
||||
if not raw_pools:
|
||||
continue
|
||||
if not isinstance(raw_pools, dict):
|
||||
raise ValueError(f"{key} in {path} must be an object")
|
||||
for name, entries in raw_pools.items():
|
||||
pool_name = str(name).strip()
|
||||
if not pool_name:
|
||||
continue
|
||||
pools.setdefault(pool_name, [])
|
||||
_unique_extend(pools[pool_name], _list_from(entries))
|
||||
return pools
|
||||
|
||||
|
||||
def load_scene_pool_library() -> dict[str, list[Any]]:
|
||||
return load_named_pool_library("scene_pools")
|
||||
|
||||
|
||||
def load_expression_pool_library() -> dict[str, list[Any]]:
|
||||
return load_named_pool_library("expression_pools")
|
||||
|
||||
|
||||
def load_composition_pool_library() -> dict[str, list[Any]]:
|
||||
return load_named_pool_library("composition_pools")
|
||||
|
||||
|
||||
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 exact_subcategory_selector(category: dict[str, Any], subcategory: dict[str, Any]) -> str:
|
||||
return f"{category.get('name')} / {subcategory.get('name')}"
|
||||
|
||||
|
||||
def split_exact_subcategory_choice(
|
||||
categories: list[dict[str, Any]],
|
||||
subcategory_choice: str,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
choice = str(subcategory_choice or "").strip()
|
||||
if not choice or " / " not in choice:
|
||||
return None
|
||||
candidates: list[tuple[int, dict[str, Any], str]] = []
|
||||
for category in categories:
|
||||
for category_label in (category.get("name", ""), category.get("slug", "")):
|
||||
category_label = str(category_label).strip()
|
||||
prefix = f"{category_label} / "
|
||||
if category_label and choice.lower().startswith(prefix.lower()):
|
||||
candidates.append((len(prefix), category, choice[len(prefix) :].strip()))
|
||||
if candidates:
|
||||
_length, category, subcategory_name = max(candidates, key=lambda candidate: candidate[0])
|
||||
return category, subcategory_name
|
||||
return None
|
||||
|
||||
|
||||
def _base_cast_counts(women_count: int, men_count: int) -> tuple[int, int]:
|
||||
women_count = max(0, int(women_count))
|
||||
men_count = max(0, int(men_count))
|
||||
if women_count + men_count == 0:
|
||||
women_count = 1
|
||||
return women_count, men_count
|
||||
|
||||
|
||||
def _counts_for_exact_subcategory(
|
||||
subcategory: dict[str, Any],
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
) -> tuple[int, int]:
|
||||
women_count, men_count = _base_cast_counts(women_count, men_count)
|
||||
|
||||
min_women = _constraint_int(subcategory, "min_women")
|
||||
if min_women is not None and women_count < min_women:
|
||||
women_count = min_women
|
||||
min_men = _constraint_int(subcategory, "min_men")
|
||||
if min_men is not None and men_count < min_men:
|
||||
men_count = min_men
|
||||
|
||||
min_people = _constraint_int(subcategory, "min_people")
|
||||
if min_people is not None:
|
||||
missing = min_people - (women_count + men_count)
|
||||
if missing > 0:
|
||||
if women_count > 0 or men_count == 0:
|
||||
women_count += missing
|
||||
else:
|
||||
men_count += missing
|
||||
return women_count, men_count
|
||||
|
||||
|
||||
def find_subcategory(
|
||||
categories: list[dict[str, Any]],
|
||||
category_choice: str,
|
||||
subcategory_choice: str,
|
||||
category_rng: random.Random,
|
||||
subcategory_rng: random.Random,
|
||||
women_count: int = 1,
|
||||
men_count: int = 1,
|
||||
random_subcategory: str = RANDOM_SUBCATEGORY,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], int, int]:
|
||||
women_count, men_count = _base_cast_counts(women_count, men_count)
|
||||
if subcategory_choice and subcategory_choice != random_subcategory and " / " in subcategory_choice:
|
||||
exact_choice = split_exact_subcategory_choice(categories, subcategory_choice)
|
||||
if not exact_choice:
|
||||
category_name = str(subcategory_choice).split(" / ", 1)[0]
|
||||
raise ValueError(f"Unknown category in subcategory picker: {category_name}")
|
||||
category, subcategory_name = exact_choice
|
||||
wanted = subcategory_name.strip().lower()
|
||||
for subcategory in category["subcategories"]:
|
||||
if subcategory["name"].lower() == wanted or subcategory["slug"].lower() == wanted:
|
||||
adjusted_women_count, adjusted_men_count = _counts_for_exact_subcategory(
|
||||
subcategory,
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
if not compatible_entry(subcategory, adjusted_women_count, adjusted_men_count):
|
||||
raise ValueError(
|
||||
f"Subcategory '{subcategory['name']}' is not compatible with "
|
||||
f"women_count={women_count}, men_count={men_count}"
|
||||
)
|
||||
return category, subcategory, adjusted_women_count, adjusted_men_count
|
||||
raise ValueError(f"Unknown subcategory '{subcategory_name}' for category '{category['name']}'")
|
||||
|
||||
if category_choice == "custom_random":
|
||||
if not categories:
|
||||
raise ValueError("No custom categories found in categories/*.json")
|
||||
category = _weighted_choice(category_rng, categories)
|
||||
else:
|
||||
category = find_category(categories, category_choice)
|
||||
if not category:
|
||||
raise ValueError(f"Unknown custom category: {category_choice}")
|
||||
subcategories = compatible_entries(category["subcategories"], women_count, men_count)
|
||||
subcategory = _weighted_choice(subcategory_rng, subcategories)
|
||||
return category, subcategory, women_count, men_count
|
||||
|
||||
|
||||
def merged_field(category: dict[str, Any], subcategory: dict[str, Any], item: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(item, dict) and key in item:
|
||||
return item[key]
|
||||
if key in subcategory:
|
||||
return subcategory[key]
|
||||
if key in category:
|
||||
return category[key]
|
||||
return default
|
||||
|
||||
|
||||
def _sources_with_inheritance(
|
||||
category: dict[str, Any],
|
||||
subcategory: dict[str, Any],
|
||||
item: Any,
|
||||
inherit_key: str,
|
||||
) -> tuple[Any, ...]:
|
||||
item_source = item if isinstance(item, dict) else None
|
||||
if item_source is not None and _is_false(item_source.get(inherit_key)):
|
||||
return (item_source,)
|
||||
if _is_false(subcategory.get(inherit_key)):
|
||||
return (subcategory, item_source)
|
||||
return (category, subcategory, item_source)
|
||||
|
||||
|
||||
def configured_pool(
|
||||
category: dict[str, Any],
|
||||
subcategory: dict[str, Any],
|
||||
item: Any,
|
||||
direct_key: str,
|
||||
pool_key: str,
|
||||
pool_library: dict[str, list[Any]],
|
||||
inherit_key: str,
|
||||
) -> list[Any]:
|
||||
entries: list[Any] = []
|
||||
singular_pool_key = pool_key[:-1] if pool_key.endswith("s") else pool_key
|
||||
for source in _sources_with_inheritance(category, subcategory, item, inherit_key):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
if direct_key in source:
|
||||
_unique_extend(entries, _list_from(source[direct_key]))
|
||||
refs = _list_from(source.get(singular_pool_key)) + _list_from(source.get(pool_key))
|
||||
for ref in refs:
|
||||
ref_name = str(ref).strip()
|
||||
if ref_name not in pool_library:
|
||||
raise ValueError(f"Unknown {singular_pool_key} '{ref_name}'")
|
||||
_unique_extend(entries, pool_library[ref_name])
|
||||
return entries
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .hardcore_action_metadata import normalize_hardcore_action_family
|
||||
from .hardcore_position_config import normalize_hardcore_position_family, normalize_hardcore_position_values
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from hardcore_action_metadata import normalize_hardcore_action_family
|
||||
from hardcore_position_config import normalize_hardcore_position_family, normalize_hardcore_position_values
|
||||
|
||||
|
||||
TEMPLATE_METADATA_KEYS = (
|
||||
"action_family",
|
||||
"action_type",
|
||||
"family",
|
||||
"position_family",
|
||||
"position_key",
|
||||
"position_keys",
|
||||
"formatter_hint",
|
||||
)
|
||||
FORMATTER_HINT_ROUTES = ("all", "krea", "sdxl", "caption")
|
||||
FORMATTER_HINT_ROUTE_ALIASES = {
|
||||
"krea2": "krea",
|
||||
"naturalizer": "caption",
|
||||
"training_caption": "caption",
|
||||
}
|
||||
|
||||
|
||||
def template_metadata(item: Any) -> dict[str, Any]:
|
||||
if not isinstance(item, dict):
|
||||
return {}
|
||||
return {key: item[key] for key in TEMPLATE_METADATA_KEYS if key in item}
|
||||
|
||||
|
||||
def merge_template_metadata(*metadata_values: Any) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {}
|
||||
for value in metadata_values:
|
||||
metadata = template_metadata(value)
|
||||
if not metadata:
|
||||
continue
|
||||
for key in ("action_family", "action_type", "family", "position_family", "position_key"):
|
||||
if str(metadata.get(key) or "").strip():
|
||||
merged[key] = metadata[key]
|
||||
if metadata.get("position_keys") is not None:
|
||||
merged["position_keys"] = merge_position_keys(
|
||||
template_position_keys(merged),
|
||||
template_position_keys(metadata),
|
||||
)
|
||||
hint_map = formatter_hints(metadata)
|
||||
if hint_map:
|
||||
existing = formatter_hints(merged)
|
||||
for route, hints in hint_map.items():
|
||||
for hint in hints:
|
||||
if hint not in existing.setdefault(route, []):
|
||||
existing[route].append(hint)
|
||||
merged["formatter_hint"] = existing
|
||||
return merged
|
||||
|
||||
|
||||
def inherited_template_metadata(*containers: Any) -> dict[str, Any]:
|
||||
metadata_parts: list[dict[str, Any]] = []
|
||||
for container in containers:
|
||||
if not isinstance(container, dict):
|
||||
continue
|
||||
nested = container.get("item_template_metadata")
|
||||
if isinstance(nested, dict):
|
||||
metadata_parts.append(nested)
|
||||
metadata_parts.append(container)
|
||||
return merge_template_metadata(*metadata_parts)
|
||||
|
||||
|
||||
def template_position_family(metadata: dict[str, Any]) -> str:
|
||||
return normalize_hardcore_position_family(
|
||||
metadata.get("position_family") or metadata.get("family"),
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
def template_position_keys(metadata: dict[str, Any]) -> list[str]:
|
||||
keys: list[Any] = []
|
||||
if metadata.get("position_keys") is not None:
|
||||
raw_keys = metadata.get("position_keys")
|
||||
keys.extend(raw_keys if isinstance(raw_keys, list) else [raw_keys])
|
||||
if metadata.get("position_key") is not None:
|
||||
keys.append(metadata.get("position_key"))
|
||||
return normalize_hardcore_position_values(keys)
|
||||
|
||||
|
||||
def template_action_family(metadata: dict[str, Any]) -> str:
|
||||
return normalize_hardcore_action_family(metadata.get("action_family") or metadata.get("action_type"), "")
|
||||
|
||||
|
||||
def _list_from(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def _clean_hint(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def normalize_formatter_route(value: Any) -> str:
|
||||
route = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
||||
route = FORMATTER_HINT_ROUTE_ALIASES.get(route, route)
|
||||
return route if route in FORMATTER_HINT_ROUTES else ""
|
||||
|
||||
|
||||
def formatter_hints(metadata: dict[str, Any]) -> dict[str, list[str]]:
|
||||
raw = metadata.get("formatter_hint")
|
||||
if raw is None:
|
||||
return {}
|
||||
normalized: dict[str, list[str]] = {}
|
||||
|
||||
def add(route: str, values: Any) -> None:
|
||||
route = normalize_formatter_route(route)
|
||||
if not route:
|
||||
return
|
||||
for value in _list_from(values):
|
||||
hint = _clean_hint(value)
|
||||
if hint and hint not in normalized.setdefault(route, []):
|
||||
normalized[route].append(hint)
|
||||
|
||||
if isinstance(raw, dict):
|
||||
for route, values in raw.items():
|
||||
add(str(route), values)
|
||||
else:
|
||||
add("all", raw)
|
||||
return {route: hints for route, hints in normalized.items() if hints}
|
||||
|
||||
|
||||
def formatter_hints_for_route(row_or_hints: Any, route: str) -> list[str]:
|
||||
route = normalize_formatter_route(route)
|
||||
if not route or not isinstance(row_or_hints, dict):
|
||||
return []
|
||||
|
||||
if isinstance(row_or_hints.get("formatter_hints"), dict):
|
||||
raw_hints = row_or_hints.get("formatter_hints") or {}
|
||||
elif "formatter_hint" in row_or_hints:
|
||||
raw_hints = formatter_hints(row_or_hints)
|
||||
elif row_or_hints and all(normalize_formatter_route(raw_route) for raw_route in row_or_hints):
|
||||
raw_hints = row_or_hints
|
||||
else:
|
||||
return []
|
||||
|
||||
normalized: dict[str, list[str]] = {}
|
||||
if isinstance(raw_hints, dict):
|
||||
for raw_route, values in raw_hints.items():
|
||||
normalized_route = normalize_formatter_route(raw_route)
|
||||
if not normalized_route:
|
||||
continue
|
||||
for value in _list_from(values):
|
||||
hint = _clean_hint(value)
|
||||
if hint and hint not in normalized.setdefault(normalized_route, []):
|
||||
normalized[normalized_route].append(hint)
|
||||
|
||||
hints: list[str] = []
|
||||
for raw_route in ("all", route):
|
||||
for hint in normalized.get(raw_route, []):
|
||||
if hint not in hints:
|
||||
hints.append(hint)
|
||||
return hints
|
||||
|
||||
|
||||
def merge_position_keys(primary: list[str], fallback: list[str]) -> list[str]:
|
||||
merged: list[str] = []
|
||||
for key in [*primary, *fallback]:
|
||||
if key and key not in merged:
|
||||
merged.append(key)
|
||||
return merged
|
||||
|
||||
|
||||
def _position_key_slug(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
||||
|
||||
|
||||
def template_metadata_errors(metadata: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
raw_action_family = metadata.get("action_family") or metadata.get("action_type")
|
||||
if raw_action_family and not template_action_family(metadata):
|
||||
errors.append(f"unknown action_family/action_type: {raw_action_family}")
|
||||
raw_position_family = metadata.get("position_family") or metadata.get("family")
|
||||
if raw_position_family and not template_position_family(metadata):
|
||||
errors.append(f"unknown position_family/family: {raw_position_family}")
|
||||
raw_position_keys = []
|
||||
if metadata.get("position_keys") is not None:
|
||||
values = metadata.get("position_keys")
|
||||
raw_position_keys.extend(values if isinstance(values, list) else [values])
|
||||
if metadata.get("position_key") is not None:
|
||||
raw_position_keys.append(metadata.get("position_key"))
|
||||
normalized_keys = template_position_keys(metadata)
|
||||
invalid_keys = [
|
||||
str(value)
|
||||
for value in raw_position_keys
|
||||
if str(value or "").strip()
|
||||
and str(value or "").strip() != "any"
|
||||
and _position_key_slug(value) not in normalized_keys
|
||||
]
|
||||
if invalid_keys:
|
||||
errors.append("unknown position key(s): " + ", ".join(invalid_keys))
|
||||
raw_hint = metadata.get("formatter_hint")
|
||||
if raw_hint is not None:
|
||||
if isinstance(raw_hint, dict):
|
||||
for route, values in raw_hint.items():
|
||||
if not normalize_formatter_route(route):
|
||||
errors.append(f"unknown formatter_hint route: {route}")
|
||||
invalid_values = [
|
||||
repr(value)
|
||||
for value in _list_from(values)
|
||||
if not isinstance(value, str) or not value.strip()
|
||||
]
|
||||
if invalid_values:
|
||||
errors.append(f"invalid formatter_hint value(s) for {route}: " + ", ".join(invalid_values))
|
||||
else:
|
||||
invalid_values = [
|
||||
repr(value)
|
||||
for value in _list_from(raw_hint)
|
||||
if not isinstance(value, str) or not value.strip()
|
||||
]
|
||||
if invalid_values:
|
||||
errors.append("invalid formatter_hint value(s): " + ", ".join(invalid_values))
|
||||
return errors
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import character_config as character_policy
|
||||
from . import character_profile as character_profile_policy
|
||||
from . import character_slot as character_slot_policy
|
||||
from . import generate_prompt_batches as g
|
||||
from . import seed_config as seed_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import character_config as character_policy
|
||||
import character_profile as character_profile_policy
|
||||
import character_slot as character_slot_policy
|
||||
import generate_prompt_batches as g
|
||||
import seed_config as seed_policy
|
||||
|
||||
|
||||
def _choose(rng: random.Random, items: list[Any]) -> Any:
|
||||
return items[rng.randrange(len(items))]
|
||||
|
||||
|
||||
def slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
|
||||
if not slot:
|
||||
return ""
|
||||
outfit = character_policy.slot_value(slot.get("softcore_outfit"))
|
||||
if outfit:
|
||||
return outfit
|
||||
if rng is None:
|
||||
return ""
|
||||
return character_policy.characteristic_choice(
|
||||
character_policy.parse_characteristics_config(slot.get("characteristics")),
|
||||
"softcore_outfits",
|
||||
rng,
|
||||
)
|
||||
|
||||
|
||||
def slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
|
||||
if not slot:
|
||||
return ""
|
||||
clothing = character_policy.slot_value(slot.get("hardcore_clothing"))
|
||||
if clothing:
|
||||
return clothing
|
||||
if rng is None:
|
||||
return ""
|
||||
return character_policy.characteristic_choice(
|
||||
character_policy.parse_characteristics_config(slot.get("characteristics")),
|
||||
"hardcore_clothing",
|
||||
rng,
|
||||
)
|
||||
|
||||
|
||||
def hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str:
|
||||
hair_config = character_policy.parse_hair_config(slot.get("hair_config"))
|
||||
color_choice = character_policy.normalize_hair_choice(slot.get("hair_color"), character_policy.CHARACTER_HAIR_COLOR_CHOICES)
|
||||
length_choice = character_policy.normalize_hair_choice(slot.get("hair_length"), character_policy.CHARACTER_HAIR_LENGTH_CHOICES)
|
||||
style_choice = character_policy.normalize_hair_choice(slot.get("hair_style"), character_policy.CHARACTER_HAIR_STYLE_CHOICES)
|
||||
color_options = hair_config.get("colors") or []
|
||||
length_options = hair_config.get("lengths") or []
|
||||
style_options = hair_config.get("styles") or []
|
||||
if (
|
||||
color_choice == "random"
|
||||
and length_choice == "random"
|
||||
and style_choice == "random"
|
||||
and not color_options
|
||||
and not length_options
|
||||
and not style_options
|
||||
):
|
||||
return ""
|
||||
if color_choice != "random":
|
||||
color_key = color_choice
|
||||
elif color_options:
|
||||
color_key = _choose(rng, color_options)
|
||||
else:
|
||||
color_key = character_policy.infer_hair_color_key(base_hair)
|
||||
|
||||
if length_choice != "random":
|
||||
length_key = length_choice
|
||||
elif length_options:
|
||||
length_key = _choose(rng, length_options)
|
||||
else:
|
||||
length_key = character_policy.infer_hair_length_key(base_hair)
|
||||
|
||||
if style_choice != "random":
|
||||
style_key = style_choice
|
||||
elif style_options:
|
||||
style_key = _choose(rng, style_options)
|
||||
else:
|
||||
style_key = character_policy.infer_hair_style_key(base_hair)
|
||||
if color_key == "random":
|
||||
color_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_COLOR_CHOICES)
|
||||
if length_key == "random":
|
||||
length_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_LENGTH_CHOICES)
|
||||
if style_key == "random":
|
||||
style_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_STYLE_CHOICES)
|
||||
if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"):
|
||||
style_key = _choose(rng, ["ponytail", "braid", "bun", "messy_bun"])
|
||||
return character_policy.hair_phrase_from_parts(color_key, length_key, style_key)
|
||||
|
||||
|
||||
def appearance_for_subject(
|
||||
rng: random.Random,
|
||||
subject_type: str,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
) -> dict[str, str]:
|
||||
if subject_type == "single_any":
|
||||
subject_type = "woman" if rng.random() < 0.82 else "man"
|
||||
|
||||
if subject_type == "man":
|
||||
men_ethnicity = ethnicity if ethnicity else "any"
|
||||
subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity))
|
||||
return {
|
||||
"subject_type": "man",
|
||||
"subject": subject,
|
||||
"subject_phrase": subject,
|
||||
"age": age,
|
||||
"body": body,
|
||||
"skin": skin,
|
||||
"hair": hair,
|
||||
"eyes": eyes,
|
||||
"body_phrase": f"{body} figure",
|
||||
}
|
||||
|
||||
subject, age, body, skin, hair, eyes = g.choose_woman(rng, ethnicity, no_plus_women, no_black)
|
||||
figure_note = g.choose(rng, g.figure_pool(figure))
|
||||
return {
|
||||
"subject_type": "woman",
|
||||
"subject": subject,
|
||||
"subject_phrase": subject,
|
||||
"age": age,
|
||||
"body": body,
|
||||
"skin": skin,
|
||||
"hair": hair,
|
||||
"eyes": eyes,
|
||||
"body_phrase": character_profile_policy.body_phrase(body, figure_note),
|
||||
"figure": figure_note,
|
||||
}
|
||||
|
||||
|
||||
def context_from_character_slot(
|
||||
rng: random.Random,
|
||||
slot: dict[str, Any],
|
||||
subject_type: str,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
) -> dict[str, Any]:
|
||||
slot_ethnicity = character_policy.slot_value(slot.get("ethnicity"))
|
||||
slot_body = character_policy.slot_value(slot.get("body"))
|
||||
effective_ethnicity = slot_ethnicity or ethnicity
|
||||
effective_figure = character_slot_policy.slot_effective_figure(slot, subject_type, figure)
|
||||
effective_no_plus = bool(no_plus_women) and not slot_body
|
||||
effective_no_black = bool(no_black) and not slot_ethnicity
|
||||
appearance_rng = character_slot_policy.slot_context_rng(slot, rng)
|
||||
context = appearance_for_subject(
|
||||
appearance_rng,
|
||||
subject_type,
|
||||
effective_ethnicity,
|
||||
effective_figure,
|
||||
effective_no_plus,
|
||||
effective_no_black,
|
||||
)
|
||||
|
||||
characteristics = character_policy.parse_characteristics_config(slot.get("characteristics"))
|
||||
age = character_policy.slot_value(slot.get("age")) or character_policy.characteristic_choice(characteristics, "ages", appearance_rng)
|
||||
body_phrase = character_policy.slot_value(slot.get("body_phrase"))
|
||||
if not slot_body:
|
||||
slot_body = character_policy.characteristic_choice(characteristics, "bodies", appearance_rng)
|
||||
if age:
|
||||
context["age"] = age
|
||||
if slot_body:
|
||||
context["body"] = slot_body
|
||||
if subject_type == "woman":
|
||||
context["body_phrase"] = character_profile_policy.body_phrase(slot_body, context.get("figure", ""))
|
||||
else:
|
||||
context["body_phrase"] = f"{slot_body} figure"
|
||||
if body_phrase:
|
||||
context["body_phrase"] = body_phrase
|
||||
skin_value = character_policy.slot_value(slot.get("skin"))
|
||||
if skin_value:
|
||||
context["skin"] = skin_value
|
||||
eyes_value = character_policy.slot_value(slot.get("eyes"))
|
||||
if not eyes_value:
|
||||
eyes_value = character_policy.eye_phrase_from_key(character_policy.characteristic_choice(characteristics, "eyes", appearance_rng))
|
||||
if eyes_value:
|
||||
context["eyes"] = eyes_value
|
||||
hair_value = character_policy.slot_value(slot.get("hair"))
|
||||
if hair_value:
|
||||
context["hair"] = hair_value
|
||||
else:
|
||||
hair_descriptor = hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng)
|
||||
if hair_descriptor:
|
||||
context["hair"] = hair_descriptor
|
||||
context["descriptor_detail"] = character_policy.normalize_descriptor_detail(slot.get("descriptor_detail"))
|
||||
context["presence_mode"] = character_policy.normalize_presence_mode(slot.get("presence_mode"), subject_type)
|
||||
context["expression_enabled"] = character_slot_policy.slot_expression_enabled(slot)
|
||||
expression_intensity = character_slot_policy.slot_expression_intensity(slot)
|
||||
if expression_intensity is not None:
|
||||
context["expression_intensity"] = expression_intensity
|
||||
context["subject_type"] = subject_type
|
||||
context["subject"] = subject_type
|
||||
context["subject_phrase"] = subject_type
|
||||
return context
|
||||
|
||||
|
||||
def character_context_for_label(
|
||||
label: str,
|
||||
label_map: dict[str, dict[str, Any]],
|
||||
rng: random.Random,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
subject_type = "man" if label.startswith("Man ") else "woman"
|
||||
slot = label_map.get(label)
|
||||
if slot:
|
||||
return context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot
|
||||
return appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None
|
||||
|
||||
|
||||
def apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in (
|
||||
"subject_type",
|
||||
"subject",
|
||||
"subject_phrase",
|
||||
"age",
|
||||
"body",
|
||||
"body_phrase",
|
||||
"skin",
|
||||
"hair",
|
||||
"eyes",
|
||||
"figure",
|
||||
"descriptor_detail",
|
||||
"presence_mode",
|
||||
"expression_enabled",
|
||||
"expression_intensity",
|
||||
):
|
||||
value = context.get(key)
|
||||
if value is not None and value != "":
|
||||
row[key] = value
|
||||
if context.get("age"):
|
||||
row["age_band"] = context["age"]
|
||||
return row
|
||||
|
||||
|
||||
def row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
slots = character_slot_policy.parse_character_cast(character_slot)
|
||||
if not slots:
|
||||
return {}
|
||||
slot = slots[-1]
|
||||
if character_slot_policy.slot_seed(slot) >= 0:
|
||||
subject_type = str(slot.get("subject_type") or "woman")
|
||||
return context_from_character_slot(
|
||||
random.Random(seed_policy.row_seed(character_slot_policy.slot_seed(slot), 1, 719)),
|
||||
slot,
|
||||
subject_type,
|
||||
"any",
|
||||
"curvy",
|
||||
False,
|
||||
False,
|
||||
)
|
||||
return slot
|
||||
@@ -0,0 +1,688 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
CHARACTER_LABEL_CHOICES = [
|
||||
"auto_chain",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
]
|
||||
CHARACTER_AGE_CHOICES = (
|
||||
["random", "manual"]
|
||||
+ [f"{age}-year-old adult" for age in range(21, 86)]
|
||||
+ [
|
||||
"late 20s adult",
|
||||
"early 30s adult",
|
||||
"mid 30s adult",
|
||||
"late 30s adult",
|
||||
"early 40s adult",
|
||||
"mid 40s adult",
|
||||
"late 40s adult",
|
||||
"early 50s adult",
|
||||
"mid 50s adult",
|
||||
"late 50s adult",
|
||||
"early 60s adult",
|
||||
"mid 60s adult",
|
||||
"late 60s adult",
|
||||
"early 70s adult",
|
||||
"mid 70s adult",
|
||||
"late 70s adult",
|
||||
"early 80s adult",
|
||||
]
|
||||
)
|
||||
CHARACTER_BODY_CHOICES = [
|
||||
"random",
|
||||
"manual",
|
||||
"slim",
|
||||
"petite adult",
|
||||
"toned",
|
||||
"athletic",
|
||||
"average",
|
||||
"curvy",
|
||||
"soft curvy",
|
||||
"curvy athletic",
|
||||
"hourglass",
|
||||
"slim busty",
|
||||
"busty",
|
||||
"busty curvy",
|
||||
"voluptuous",
|
||||
"plus-size",
|
||||
"heavyset",
|
||||
"fat",
|
||||
"stocky",
|
||||
"broad",
|
||||
"muscular",
|
||||
]
|
||||
CHARACTER_WOMAN_BODY_CHOICES = [
|
||||
"random",
|
||||
"manual",
|
||||
"slim",
|
||||
"petite adult",
|
||||
"toned",
|
||||
"athletic",
|
||||
"average",
|
||||
"curvy",
|
||||
"soft curvy",
|
||||
"curvy athletic",
|
||||
"hourglass",
|
||||
"slim busty",
|
||||
"busty",
|
||||
"busty curvy",
|
||||
"voluptuous",
|
||||
"plus-size",
|
||||
"heavyset",
|
||||
"fat",
|
||||
]
|
||||
CHARACTER_MAN_BODY_CHOICES = [
|
||||
"random",
|
||||
"manual",
|
||||
"slim",
|
||||
"lean",
|
||||
"lean athletic",
|
||||
"toned",
|
||||
"average",
|
||||
"athletic",
|
||||
"muscular",
|
||||
"broad",
|
||||
"broad-shouldered",
|
||||
"stocky",
|
||||
"heavyset",
|
||||
"fat",
|
||||
]
|
||||
CHARACTER_DESCRIPTOR_DETAIL_CHOICES = ["auto", "full", "medium", "compact", "minimal"]
|
||||
CHARACTER_PRESENCE_CHOICES = ["visible", "pov"]
|
||||
CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
|
||||
CHARACTER_SLOT_SEED_MAX = 0xFFFFFFFF
|
||||
CHARACTER_FIGURE_CHOICES = ["random", "curvy", "balanced", "bombshell"]
|
||||
CHARACTER_HAIR_COLOR_CHOICES = [
|
||||
"random",
|
||||
"black",
|
||||
"brown",
|
||||
"dark_brown",
|
||||
"chestnut",
|
||||
"auburn",
|
||||
"copper",
|
||||
"red",
|
||||
"blonde",
|
||||
"platinum_blonde",
|
||||
"ash_blonde",
|
||||
"honey_blonde",
|
||||
"strawberry_blonde",
|
||||
"dark_blonde",
|
||||
"silver_gray",
|
||||
"white",
|
||||
]
|
||||
CHARACTER_HAIR_LENGTH_CHOICES = [
|
||||
"random",
|
||||
"very_short",
|
||||
"short",
|
||||
"bob_lob",
|
||||
"shoulder_length",
|
||||
"medium",
|
||||
"long",
|
||||
"very_long",
|
||||
"updo",
|
||||
]
|
||||
CHARACTER_HAIR_STYLE_CHOICES = [
|
||||
"random",
|
||||
"straight",
|
||||
"waves",
|
||||
"loose_waves",
|
||||
"curls",
|
||||
"tight_curls",
|
||||
"pixie_cut",
|
||||
"bob",
|
||||
"lob",
|
||||
"shag",
|
||||
"ponytail",
|
||||
"braid",
|
||||
"braids",
|
||||
"bun",
|
||||
"messy_bun",
|
||||
"locs",
|
||||
"twists",
|
||||
"afro",
|
||||
"natural_curls",
|
||||
"wet_hair",
|
||||
"slicked_back",
|
||||
]
|
||||
CHARACTER_EYE_COLOR_CHOICES = [
|
||||
"random",
|
||||
"blue",
|
||||
"pale_blue",
|
||||
"ice_blue",
|
||||
"blue_gray",
|
||||
"green",
|
||||
"emerald_green",
|
||||
"hazel",
|
||||
"light_hazel",
|
||||
"green_hazel",
|
||||
"amber",
|
||||
"amber_brown",
|
||||
"honey_brown",
|
||||
"brown",
|
||||
"deep_brown",
|
||||
"dark_brown",
|
||||
"dark",
|
||||
"gray",
|
||||
"gray_brown",
|
||||
]
|
||||
CHARACTER_CHARACTERISTIC_AXES = {
|
||||
"ages": CHARACTER_AGE_CHOICES,
|
||||
"bodies": list(dict.fromkeys([*CHARACTER_BODY_CHOICES, *CHARACTER_WOMAN_BODY_CHOICES, *CHARACTER_MAN_BODY_CHOICES])),
|
||||
"eyes": CHARACTER_EYE_COLOR_CHOICES,
|
||||
}
|
||||
|
||||
|
||||
def character_label_choices() -> list[str]:
|
||||
return list(CHARACTER_LABEL_CHOICES)
|
||||
|
||||
|
||||
def character_age_choices() -> list[str]:
|
||||
return list(CHARACTER_AGE_CHOICES)
|
||||
|
||||
|
||||
def character_body_choices() -> list[str]:
|
||||
return list(CHARACTER_BODY_CHOICES)
|
||||
|
||||
|
||||
def character_woman_body_choices() -> list[str]:
|
||||
return list(CHARACTER_WOMAN_BODY_CHOICES)
|
||||
|
||||
|
||||
def character_man_body_choices() -> list[str]:
|
||||
return list(CHARACTER_MAN_BODY_CHOICES)
|
||||
|
||||
|
||||
def character_descriptor_detail_choices() -> list[str]:
|
||||
return list(CHARACTER_DESCRIPTOR_DETAIL_CHOICES)
|
||||
|
||||
|
||||
def character_presence_choices() -> list[str]:
|
||||
return list(CHARACTER_PRESENCE_CHOICES)
|
||||
|
||||
|
||||
def character_figure_choices() -> list[str]:
|
||||
return list(CHARACTER_FIGURE_CHOICES)
|
||||
|
||||
|
||||
def character_hair_color_choices() -> list[str]:
|
||||
return list(CHARACTER_HAIR_COLOR_CHOICES)
|
||||
|
||||
|
||||
def character_hair_length_choices() -> list[str]:
|
||||
return list(CHARACTER_HAIR_LENGTH_CHOICES)
|
||||
|
||||
|
||||
def character_hair_style_choices() -> list[str]:
|
||||
return list(CHARACTER_HAIR_STYLE_CHOICES)
|
||||
|
||||
|
||||
def character_eye_color_choices() -> list[str]:
|
||||
return list(CHARACTER_EYE_COLOR_CHOICES)
|
||||
|
||||
|
||||
def slot_value(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if text.lower() in CHARACTER_RANDOM_TOKENS:
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def normalize_descriptor_detail(value: Any) -> str:
|
||||
text = str(value or "auto").strip()
|
||||
return text if text in CHARACTER_DESCRIPTOR_DETAIL_CHOICES else "auto"
|
||||
|
||||
|
||||
def normalize_presence_mode(value: Any, subject_type: str) -> str:
|
||||
text = str(value or "visible").strip().lower()
|
||||
if text not in CHARACTER_PRESENCE_CHOICES:
|
||||
text = "visible"
|
||||
if subject_type != "man":
|
||||
return "visible"
|
||||
return text
|
||||
|
||||
|
||||
def normalize_slot_seed(value: Any) -> int:
|
||||
try:
|
||||
seed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
if seed < 0:
|
||||
return -1
|
||||
return min(seed, CHARACTER_SLOT_SEED_MAX)
|
||||
|
||||
|
||||
def empty_characteristics_config() -> dict[str, Any]:
|
||||
return {
|
||||
"config_type": "characteristics",
|
||||
"ages": [],
|
||||
"bodies": [],
|
||||
"eyes": [],
|
||||
"softcore_outfits": [],
|
||||
"hardcore_clothing": [],
|
||||
}
|
||||
|
||||
|
||||
def normalize_characteristic_choice(value: Any, choices: list[str] | tuple[str, ...]) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
||||
for choice in choices:
|
||||
if normalized == re.sub(r"[^a-z0-9]+", "_", str(choice).lower()).strip("_"):
|
||||
return str(choice)
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_characteristic_values(
|
||||
values: Any,
|
||||
choices: list[str] | tuple[str, ...] | None = None,
|
||||
*,
|
||||
allow_free_text: bool = False,
|
||||
) -> list[str]:
|
||||
if isinstance(values, str):
|
||||
raw_values = [part.strip() for part in re.split(r"[\n;]+", values) if part.strip()]
|
||||
if len(raw_values) == 1 and "," in raw_values[0] and not allow_free_text:
|
||||
raw_values = [part.strip() for part in raw_values[0].split(",") if part.strip()]
|
||||
elif isinstance(values, (list, tuple, set)):
|
||||
raw_values = list(values)
|
||||
else:
|
||||
raw_values = []
|
||||
normalized: list[str] = []
|
||||
for raw_value in raw_values:
|
||||
value = str(raw_value or "").strip() if choices is None else normalize_characteristic_choice(raw_value, choices)
|
||||
if not value or value in ("random", "manual"):
|
||||
continue
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_characteristics_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not value:
|
||||
return empty_characteristics_config()
|
||||
if isinstance(value, dict):
|
||||
raw = value
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(value))
|
||||
except json.JSONDecodeError:
|
||||
return empty_characteristics_config()
|
||||
if not isinstance(raw, dict):
|
||||
return empty_characteristics_config()
|
||||
return {
|
||||
"config_type": "characteristics",
|
||||
"ages": normalize_characteristic_values(raw.get("ages"), CHARACTER_AGE_CHOICES),
|
||||
"bodies": normalize_characteristic_values(raw.get("bodies"), CHARACTER_CHARACTERISTIC_AXES["bodies"]),
|
||||
"eyes": normalize_characteristic_values(raw.get("eyes"), CHARACTER_EYE_COLOR_CHOICES),
|
||||
"softcore_outfits": normalize_characteristic_values(raw.get("softcore_outfits"), None, allow_free_text=True),
|
||||
"hardcore_clothing": normalize_characteristic_values(raw.get("hardcore_clothing"), None, allow_free_text=True),
|
||||
}
|
||||
|
||||
|
||||
def characteristics_summary(config: dict[str, Any]) -> str:
|
||||
parts = []
|
||||
for key, label in (
|
||||
("ages", "ages"),
|
||||
("bodies", "bodies"),
|
||||
("eyes", "eyes"),
|
||||
("softcore_outfits", "soft_outfits"),
|
||||
("hardcore_clothing", "hard_clothing"),
|
||||
):
|
||||
values = config.get(key) or []
|
||||
if not values:
|
||||
continue
|
||||
if key in ("softcore_outfits", "hardcore_clothing"):
|
||||
parts.append(f"{label}={len(values)}")
|
||||
else:
|
||||
parts.append(f"{label}={','.join(values)}")
|
||||
return "; ".join(parts) if parts else "characteristics unrestricted"
|
||||
|
||||
|
||||
def build_characteristics_config_json(
|
||||
characteristics: str | dict[str, Any] | None = "",
|
||||
axis: str = "ages",
|
||||
selected_values: list[str] | tuple[str, ...] | str | None = None,
|
||||
combine_mode: str = "replace_axis",
|
||||
) -> str:
|
||||
config = parse_characteristics_config(characteristics)
|
||||
axis_key = str(axis or "").strip().lower()
|
||||
if axis_key not in config:
|
||||
config["summary"] = characteristics_summary(config)
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
choices = CHARACTER_CHARACTERISTIC_AXES.get(axis_key)
|
||||
values = normalize_characteristic_values(
|
||||
selected_values,
|
||||
choices,
|
||||
allow_free_text=choices is None,
|
||||
)
|
||||
if combine_mode == "add_to_axis":
|
||||
existing = list(config.get(axis_key) or [])
|
||||
for value in values:
|
||||
if value not in existing:
|
||||
existing.append(value)
|
||||
config[axis_key] = existing
|
||||
else:
|
||||
config[axis_key] = values
|
||||
config["summary"] = characteristics_summary(config)
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def characteristic_choice(config: dict[str, Any], key: str, rng: random.Random) -> str:
|
||||
values = config.get(key) or []
|
||||
return values[rng.randrange(len(values))] if values else ""
|
||||
|
||||
|
||||
def eye_phrase_from_key(key: str) -> str:
|
||||
return {
|
||||
"blue": "blue eyes",
|
||||
"pale_blue": "pale blue eyes",
|
||||
"ice_blue": "ice blue eyes",
|
||||
"blue_gray": "blue-gray eyes",
|
||||
"green": "green eyes",
|
||||
"emerald_green": "emerald green eyes",
|
||||
"hazel": "hazel eyes",
|
||||
"light_hazel": "light hazel eyes",
|
||||
"green_hazel": "green-hazel eyes",
|
||||
"amber": "amber eyes",
|
||||
"amber_brown": "amber-brown eyes",
|
||||
"honey_brown": "honey-brown eyes",
|
||||
"brown": "brown eyes",
|
||||
"deep_brown": "deep brown eyes",
|
||||
"dark_brown": "dark brown eyes",
|
||||
"dark": "dark eyes",
|
||||
"gray": "gray eyes",
|
||||
"gray_brown": "gray-brown eyes",
|
||||
}.get(key, "")
|
||||
|
||||
|
||||
def normalize_hair_choice(value: Any, choices: list[str]) -> str:
|
||||
text = str(value or "random").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
return text if text in choices else "random"
|
||||
|
||||
|
||||
def infer_hair_color_key(text: Any) -> str:
|
||||
value = str(text or "").lower()
|
||||
checks = (
|
||||
("platinum_blonde", ("platinum-blonde", "platinum blonde", "platinum")),
|
||||
("strawberry_blonde", ("strawberry-blonde", "strawberry blonde")),
|
||||
("honey_blonde", ("honey-blonde", "honey blonde")),
|
||||
("ash_blonde", ("ash-blonde", "ash blonde")),
|
||||
("dark_blonde", ("dark-blonde", "dark blonde")),
|
||||
(
|
||||
"blonde",
|
||||
(
|
||||
"light-blonde",
|
||||
"light blonde",
|
||||
"blonde",
|
||||
"flaxen",
|
||||
"wheat-blonde",
|
||||
"wheat blonde",
|
||||
"beige-blonde",
|
||||
"beige blonde",
|
||||
"sandy-blonde",
|
||||
"sandy blonde",
|
||||
),
|
||||
),
|
||||
("silver_gray", ("silver-gray", "silver grey", "silver", "gray", "grey")),
|
||||
("dark_brown", ("dark-brown", "dark brown", "espresso")),
|
||||
("chestnut", ("chestnut",)),
|
||||
("auburn", ("auburn",)),
|
||||
("copper", ("copper",)),
|
||||
("red", ("red hair", "redhead")),
|
||||
("black", ("black",)),
|
||||
("brown", ("brown", "brunette", "caramel")),
|
||||
("white", ("white",)),
|
||||
)
|
||||
for key, tokens in checks:
|
||||
if any(token in value for token in tokens):
|
||||
return key
|
||||
return "random"
|
||||
|
||||
|
||||
def infer_hair_length_key(text: Any) -> str:
|
||||
value = str(text or "").lower()
|
||||
if any(token in value for token in ("very long", "waist-length", "hip-length")):
|
||||
return "very_long"
|
||||
if "long" in value:
|
||||
return "long"
|
||||
if "shoulder-length" in value or "shoulder length" in value:
|
||||
return "shoulder_length"
|
||||
if "medium-length" in value or "medium length" in value:
|
||||
return "medium"
|
||||
if any(token in value for token in ("bob", "lob")):
|
||||
return "bob_lob"
|
||||
if any(token in value for token in ("pixie", "short", "cropped", "tapered")):
|
||||
return "short"
|
||||
if any(token in value for token in ("bun", "updo")):
|
||||
return "updo"
|
||||
return "random"
|
||||
|
||||
|
||||
def infer_hair_style_key(text: Any) -> str:
|
||||
value = str(text or "").lower()
|
||||
checks = (
|
||||
("pixie_cut", ("pixie",)),
|
||||
("messy_bun", ("messy bun",)),
|
||||
("bun", ("bun", "updo")),
|
||||
("ponytail", ("ponytail",)),
|
||||
("braids", ("braids", "box braids", "cornrow")),
|
||||
("braid", ("braid",)),
|
||||
("locs", ("locs", "dreadlocks")),
|
||||
("twists", ("twists",)),
|
||||
("afro", ("afro",)),
|
||||
("natural_curls", ("natural curls", "natural coils", "coils")),
|
||||
("tight_curls", ("tight curls", "tight coils")),
|
||||
("curls", ("curls", "curly")),
|
||||
("loose_waves", ("loose waves",)),
|
||||
("waves", ("waves", "wavy")),
|
||||
("lob", ("lob",)),
|
||||
("bob", ("bob",)),
|
||||
("shag", ("shag",)),
|
||||
("wet_hair", ("wet hair", "damp hair")),
|
||||
("slicked_back", ("slicked-back", "slicked back")),
|
||||
("straight", ("straight", "sleek")),
|
||||
)
|
||||
for key, tokens in checks:
|
||||
if any(token in value for token in tokens):
|
||||
return key
|
||||
return "random"
|
||||
|
||||
|
||||
def choose_hair_key(rng: random.Random, choices: list[str]) -> str:
|
||||
pool = [choice for choice in choices if choice != "random"]
|
||||
return pool[rng.randrange(len(pool))] if pool else "random"
|
||||
|
||||
|
||||
def normalize_hair_values(values: Any, choices: list[str]) -> list[str]:
|
||||
if isinstance(values, str):
|
||||
raw_values = [part.strip() for part in re.split(r"[,;\n]+", values) if part.strip()]
|
||||
elif isinstance(values, (list, tuple, set)):
|
||||
raw_values = list(values)
|
||||
else:
|
||||
raw_values = []
|
||||
normalized: list[str] = []
|
||||
for value in raw_values:
|
||||
key = normalize_hair_choice(value, choices)
|
||||
if key != "random" and key not in normalized:
|
||||
normalized.append(key)
|
||||
return normalized
|
||||
|
||||
|
||||
def empty_hair_config() -> dict[str, Any]:
|
||||
return {"config_type": "hair_characteristics", "colors": [], "lengths": [], "styles": []}
|
||||
|
||||
|
||||
def parse_hair_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not value:
|
||||
return empty_hair_config()
|
||||
if isinstance(value, dict):
|
||||
raw = value
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(value))
|
||||
except json.JSONDecodeError:
|
||||
return empty_hair_config()
|
||||
if not isinstance(raw, dict):
|
||||
return empty_hair_config()
|
||||
return {
|
||||
"config_type": "hair_characteristics",
|
||||
"colors": normalize_hair_values(raw.get("colors"), CHARACTER_HAIR_COLOR_CHOICES),
|
||||
"lengths": normalize_hair_values(raw.get("lengths"), CHARACTER_HAIR_LENGTH_CHOICES),
|
||||
"styles": normalize_hair_values(raw.get("styles"), CHARACTER_HAIR_STYLE_CHOICES),
|
||||
}
|
||||
|
||||
|
||||
def hair_config_summary(config: dict[str, Any]) -> str:
|
||||
parts = []
|
||||
for label, key in (("colors", "colors"), ("lengths", "lengths"), ("styles", "styles")):
|
||||
values = config.get(key) or []
|
||||
if values:
|
||||
parts.append(f"{label}={','.join(values)}")
|
||||
return "; ".join(parts) if parts else "hair unrestricted"
|
||||
|
||||
|
||||
def build_hair_config_json(
|
||||
hair_config: str | dict[str, Any] | None = "",
|
||||
axis: str = "color",
|
||||
selected_values: list[str] | tuple[str, ...] | str | None = None,
|
||||
combine_mode: str = "replace_axis",
|
||||
) -> str:
|
||||
config = parse_hair_config(hair_config)
|
||||
axis_key = {"color": "colors", "length": "lengths", "style": "styles"}.get(str(axis or "").strip().lower())
|
||||
choice_map = {
|
||||
"colors": CHARACTER_HAIR_COLOR_CHOICES,
|
||||
"lengths": CHARACTER_HAIR_LENGTH_CHOICES,
|
||||
"styles": CHARACTER_HAIR_STYLE_CHOICES,
|
||||
}
|
||||
if axis_key:
|
||||
values = normalize_hair_values(selected_values, choice_map[axis_key])
|
||||
if combine_mode == "add_to_axis":
|
||||
existing = list(config.get(axis_key) or [])
|
||||
for value in values:
|
||||
if value not in existing:
|
||||
existing.append(value)
|
||||
config[axis_key] = existing
|
||||
else:
|
||||
config[axis_key] = values
|
||||
config["summary"] = hair_config_summary(config)
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def hair_color_text(key: str) -> str:
|
||||
return {
|
||||
"black": "black",
|
||||
"brown": "brown",
|
||||
"dark_brown": "dark-brown",
|
||||
"chestnut": "chestnut",
|
||||
"auburn": "auburn",
|
||||
"copper": "copper",
|
||||
"red": "red",
|
||||
"blonde": "blonde",
|
||||
"platinum_blonde": "platinum-blonde",
|
||||
"ash_blonde": "ash-blonde",
|
||||
"honey_blonde": "honey-blonde",
|
||||
"strawberry_blonde": "strawberry-blonde",
|
||||
"dark_blonde": "dark-blonde",
|
||||
"silver_gray": "silver-gray",
|
||||
"white": "white",
|
||||
}.get(key, "brown")
|
||||
|
||||
|
||||
def hair_length_text(key: str) -> str:
|
||||
return {
|
||||
"very_short": "very short",
|
||||
"short": "short",
|
||||
"bob_lob": "",
|
||||
"shoulder_length": "shoulder-length",
|
||||
"medium": "medium-length",
|
||||
"long": "long",
|
||||
"very_long": "very long",
|
||||
"updo": "",
|
||||
}.get(key, "")
|
||||
|
||||
|
||||
def hair_phrase_from_parts(color_key: str, length_key: str, style_key: str) -> str:
|
||||
color = hair_color_text(color_key)
|
||||
length = hair_length_text(length_key)
|
||||
prefix = " ".join(part for part in (length, color) if part)
|
||||
if style_key == "pixie_cut":
|
||||
return f"short {color} pixie cut"
|
||||
if style_key == "bob":
|
||||
return f"{color} bob" if length_key in ("random", "bob_lob", "short") else f"{prefix} bob"
|
||||
if style_key == "lob":
|
||||
return f"shoulder-length {color} lob" if length_key in ("random", "bob_lob") else f"{prefix} lob"
|
||||
if style_key == "shag":
|
||||
return f"{prefix or color} shag"
|
||||
if style_key == "ponytail":
|
||||
return f"{prefix or color} ponytail"
|
||||
if style_key == "braid":
|
||||
return f"{prefix or color} braid"
|
||||
if style_key == "braids":
|
||||
return f"{prefix or color} braids"
|
||||
if style_key == "bun":
|
||||
return f"{prefix} hair in a bun" if length else f"{color} bun"
|
||||
if style_key == "messy_bun":
|
||||
return f"{prefix} hair in a messy bun" if length else f"messy {color} bun"
|
||||
if style_key == "locs":
|
||||
return f"{prefix or color} locs"
|
||||
if style_key == "twists":
|
||||
return f"{prefix or color} twists"
|
||||
if style_key == "afro":
|
||||
return f"{color} afro"
|
||||
if style_key == "natural_curls":
|
||||
return f"{prefix or color} natural curls"
|
||||
if style_key == "wet_hair":
|
||||
return f"{prefix or color} wet hair"
|
||||
if style_key == "slicked_back":
|
||||
return f"slicked-back {color} hair"
|
||||
if style_key == "straight":
|
||||
return f"{prefix or color} straight hair"
|
||||
if style_key == "loose_waves":
|
||||
return f"{prefix or color} loose waves"
|
||||
if style_key == "tight_curls":
|
||||
return f"{prefix or color} tight curls"
|
||||
if style_key == "curls":
|
||||
return f"{prefix or color} curls"
|
||||
return f"{prefix or color} waves"
|
||||
|
||||
|
||||
_slot_value = slot_value
|
||||
_normalize_descriptor_detail = normalize_descriptor_detail
|
||||
_normalize_presence_mode = normalize_presence_mode
|
||||
_normalize_slot_seed = normalize_slot_seed
|
||||
_character_figure_choices = character_figure_choices
|
||||
_empty_characteristics_config = empty_characteristics_config
|
||||
_normalize_characteristic_choice = normalize_characteristic_choice
|
||||
_normalize_characteristic_values = normalize_characteristic_values
|
||||
_parse_characteristics_config = parse_characteristics_config
|
||||
_characteristics_summary = characteristics_summary
|
||||
_characteristic_choice = characteristic_choice
|
||||
_eye_phrase_from_key = eye_phrase_from_key
|
||||
_normalize_hair_choice = normalize_hair_choice
|
||||
_infer_hair_color_key = infer_hair_color_key
|
||||
_infer_hair_length_key = infer_hair_length_key
|
||||
_infer_hair_style_key = infer_hair_style_key
|
||||
_choose_hair_key = choose_hair_key
|
||||
_normalize_hair_values = normalize_hair_values
|
||||
_empty_hair_config = empty_hair_config
|
||||
_parse_hair_config = parse_hair_config
|
||||
_hair_config_summary = hair_config_summary
|
||||
_hair_color_text = hair_color_text
|
||||
_hair_length_text = hair_length_text
|
||||
_hair_phrase_from_parts = hair_phrase_from_parts
|
||||
@@ -0,0 +1,480 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import character_config as character_policy
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import character_config as character_policy
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = ROOT_DIR / "profiles"
|
||||
CHARACTER_MANUAL_FIELDS = (
|
||||
"manual_age",
|
||||
"manual_body",
|
||||
"body_phrase",
|
||||
"skin",
|
||||
"hair",
|
||||
"eyes",
|
||||
"softcore_outfit",
|
||||
"hardcore_clothing",
|
||||
)
|
||||
|
||||
|
||||
def body_phrase(body: Any, figure_note: Any = "") -> str:
|
||||
body = str(body or "").strip()
|
||||
figure_note = str(figure_note or "").strip()
|
||||
if not body:
|
||||
return figure_note
|
||||
if not figure_note:
|
||||
return f"{body} figure"
|
||||
if "figure" in figure_note.lower():
|
||||
return f"{body} build and {figure_note}"
|
||||
return f"{body} figure with {figure_note}"
|
||||
|
||||
|
||||
def safe_profile_name(profile_name: str) -> str:
|
||||
profile_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(profile_name or "").strip()).strip("_")
|
||||
return profile_name[:64] or "profile"
|
||||
|
||||
|
||||
def profile_path(profile_name: str) -> Path:
|
||||
return PROFILE_DIR / f"{safe_profile_name(profile_name)}.json"
|
||||
|
||||
|
||||
def character_profile_choices() -> list[str]:
|
||||
if not PROFILE_DIR.exists():
|
||||
return ["manual"]
|
||||
names = sorted(path.stem for path in PROFILE_DIR.glob("*.json") if path.is_file())
|
||||
return ["manual"] + names
|
||||
|
||||
|
||||
def load_json_object(value: str | dict[str, Any] | None, label: str) -> dict[str, Any]:
|
||||
if not value:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
try:
|
||||
raw = json.loads(str(value))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid {label} JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label} must be a JSON object.")
|
||||
return raw
|
||||
|
||||
|
||||
def parse_character_manual_config(value: str | dict[str, Any] | None) -> dict[str, str]:
|
||||
if not value:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
raw = value
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(value))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {
|
||||
key: str(raw.get(key) or "").strip()
|
||||
for key in CHARACTER_MANUAL_FIELDS
|
||||
if str(raw.get(key) or "").strip()
|
||||
}
|
||||
|
||||
|
||||
def character_manual_summary(config: dict[str, str]) -> str:
|
||||
parts = [f"{key}={value}" for key, value in config.items() if value]
|
||||
return "; ".join(parts) if parts else "manual unrestricted"
|
||||
|
||||
|
||||
def build_character_manual_config_json(
|
||||
manual: str | dict[str, Any] | None = "",
|
||||
combine_mode: str = "merge_nonempty",
|
||||
manual_age: str = "",
|
||||
manual_body: str = "",
|
||||
body_phrase: str = "",
|
||||
skin: str = "",
|
||||
hair: str = "",
|
||||
eyes: str = "",
|
||||
softcore_outfit: str = "",
|
||||
hardcore_clothing: str = "",
|
||||
) -> str:
|
||||
base = {} if combine_mode == "replace_all" else parse_character_manual_config(manual)
|
||||
updates = {
|
||||
"manual_age": manual_age,
|
||||
"manual_body": manual_body,
|
||||
"body_phrase": body_phrase,
|
||||
"skin": skin,
|
||||
"hair": hair,
|
||||
"eyes": eyes,
|
||||
"softcore_outfit": softcore_outfit,
|
||||
"hardcore_clothing": hardcore_clothing,
|
||||
}
|
||||
for key, value in updates.items():
|
||||
value = str(value or "").strip()
|
||||
if value:
|
||||
base[key] = value
|
||||
result = {"config_type": "character_manual", **base}
|
||||
result["summary"] = character_manual_summary(base)
|
||||
return json.dumps(result, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def descriptor_detail_for_subject(subject: Any, descriptor_detail: Any) -> str:
|
||||
detail = character_policy.normalize_descriptor_detail(descriptor_detail)
|
||||
if detail != "auto":
|
||||
return detail
|
||||
return "compact" if str(subject or "").strip().lower() == "man" else "full"
|
||||
|
||||
|
||||
def descriptor_from_parts(
|
||||
subject: Any,
|
||||
age: Any,
|
||||
body_phrase_value: Any,
|
||||
skin: Any,
|
||||
hair: Any,
|
||||
eyes: Any,
|
||||
descriptor_detail: Any = "auto",
|
||||
) -> str:
|
||||
subject = str(subject or "person").strip() or "person"
|
||||
age_text = " ".join(str(age or "").strip().split())
|
||||
age_text = age_text.removesuffix(" adults").removesuffix(" adult").strip()
|
||||
if age_text in ("adult", "adults"):
|
||||
age_text = ""
|
||||
subject_phrase = f"{age_text} adult {subject}".strip() if age_text else f"adult {subject}"
|
||||
detail = descriptor_detail_for_subject(subject, descriptor_detail)
|
||||
detail_map = {
|
||||
"minimal": (body_phrase_value,),
|
||||
"compact": (body_phrase_value, skin),
|
||||
"medium": (body_phrase_value, skin, hair),
|
||||
"full": (body_phrase_value, skin, hair, eyes),
|
||||
}
|
||||
pieces = [subject_phrase, *detail_map.get(detail, detail_map["full"])]
|
||||
return ", ".join(str(piece).strip() for piece in pieces if piece and str(piece).strip())
|
||||
|
||||
|
||||
def row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
row = load_json_object(metadata_json, "metadata_json")
|
||||
if isinstance(row.get("softcore_row"), dict):
|
||||
return row["softcore_row"]
|
||||
return row
|
||||
|
||||
|
||||
def character_profile_descriptor(profile: dict[str, Any]) -> str:
|
||||
subject = str(profile.get("subject_type") or profile.get("subject") or "person").strip()
|
||||
return descriptor_from_parts(
|
||||
subject,
|
||||
profile.get("age"),
|
||||
profile.get("body_phrase") or body_phrase(profile.get("body"), profile.get("figure")),
|
||||
profile.get("skin"),
|
||||
profile.get("hair"),
|
||||
profile.get("eyes"),
|
||||
profile.get("descriptor_detail"),
|
||||
)
|
||||
|
||||
|
||||
def normalize_character_profile(profile: dict[str, Any], profile_name: str = "") -> dict[str, Any]:
|
||||
subject_type = str(profile.get("subject_type") or profile.get("primary_subject") or profile.get("subject") or "").strip()
|
||||
if subject_type not in ("woman", "man"):
|
||||
subject_type = "woman"
|
||||
body = str(profile.get("body") or profile.get("body_type") or "").strip()
|
||||
figure = str(profile.get("figure") or "").strip()
|
||||
normalized_body_phrase = str(profile.get("body_phrase") or "").strip() or body_phrase(body, figure)
|
||||
normalized = {
|
||||
"profile_type": "character",
|
||||
"profile_name": safe_profile_name(profile_name or str(profile.get("profile_name") or "")),
|
||||
"subject_type": subject_type,
|
||||
"subject": subject_type,
|
||||
"subject_phrase": subject_type,
|
||||
"age": str(profile.get("age") or profile.get("age_band") or "").strip(),
|
||||
"body": body,
|
||||
"body_phrase": normalized_body_phrase,
|
||||
"skin": str(profile.get("skin") or "").strip(),
|
||||
"hair": str(profile.get("hair") or "").strip(),
|
||||
"eyes": str(profile.get("eyes") or "").strip(),
|
||||
"figure": figure,
|
||||
"descriptor_detail": character_policy.normalize_descriptor_detail(profile.get("descriptor_detail")),
|
||||
}
|
||||
normalized["descriptor"] = character_profile_descriptor(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def build_character_profile_json(
|
||||
profile_name: str = "",
|
||||
source: str = "metadata_json",
|
||||
metadata_json: str | dict[str, Any] | None = "",
|
||||
character_slot_row: dict[str, Any] | None = None,
|
||||
subject_type: str = "woman",
|
||||
age: str = "",
|
||||
body: str = "",
|
||||
body_phrase_value: str = "",
|
||||
skin: str = "",
|
||||
hair: str = "",
|
||||
eyes: str = "",
|
||||
figure: str = "",
|
||||
save_now: bool = False,
|
||||
) -> dict[str, str]:
|
||||
if source == "character_slot":
|
||||
row = character_slot_row or {}
|
||||
raw_profile = {
|
||||
"profile_name": profile_name,
|
||||
"subject_type": row.get("subject_type") or subject_type,
|
||||
"age": row.get("age") or age,
|
||||
"body": row.get("body") or body,
|
||||
"body_phrase": row.get("body_phrase") or body_phrase_value,
|
||||
"skin": row.get("skin") or skin,
|
||||
"hair": row.get("hair") or hair,
|
||||
"eyes": row.get("eyes") or eyes,
|
||||
"figure": row.get("figure") or figure,
|
||||
"descriptor_detail": row.get("descriptor_detail") or "auto",
|
||||
}
|
||||
elif source == "metadata_json":
|
||||
row = row_from_profile_metadata(metadata_json)
|
||||
raw_profile = {
|
||||
"profile_name": profile_name,
|
||||
"subject_type": row.get("subject_type") or row.get("primary_subject") or subject_type,
|
||||
"age": row.get("age") or row.get("age_band") or age,
|
||||
"body": row.get("body") or row.get("body_type") or body,
|
||||
"body_phrase": row.get("body_phrase") or body_phrase_value,
|
||||
"skin": row.get("skin") or skin,
|
||||
"hair": row.get("hair") or hair,
|
||||
"eyes": row.get("eyes") or eyes,
|
||||
"figure": row.get("figure") or figure,
|
||||
"descriptor_detail": row.get("descriptor_detail") or "auto",
|
||||
}
|
||||
else:
|
||||
raw_profile = {
|
||||
"profile_name": profile_name,
|
||||
"subject_type": subject_type,
|
||||
"age": age,
|
||||
"body": body,
|
||||
"body_phrase": body_phrase_value,
|
||||
"skin": skin,
|
||||
"hair": hair,
|
||||
"eyes": eyes,
|
||||
"figure": figure,
|
||||
"descriptor_detail": "auto",
|
||||
}
|
||||
profile = normalize_character_profile(raw_profile, profile_name)
|
||||
saved_path = ""
|
||||
status = "not_saved"
|
||||
if save_now:
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = profile_path(profile["profile_name"])
|
||||
path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
saved_path = str(path)
|
||||
status = "saved"
|
||||
return {
|
||||
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
||||
"profile_name": profile["profile_name"],
|
||||
"descriptor": profile["descriptor"],
|
||||
"saved_path": saved_path,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def save_character_profile_payload(profile_name: str = "", profile_json: str | dict[str, Any] | None = "") -> dict[str, str]:
|
||||
raw_profile = load_json_object(profile_json, "profile_json")
|
||||
if not raw_profile:
|
||||
raise ValueError("No cached character profile is available to save.")
|
||||
profile = normalize_character_profile(raw_profile, profile_name or str(raw_profile.get("profile_name") or ""))
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = profile_path(profile["profile_name"])
|
||||
path.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return {
|
||||
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
||||
"profile_name": profile["profile_name"],
|
||||
"descriptor": profile["descriptor"],
|
||||
"saved_path": str(path),
|
||||
"status": "saved",
|
||||
}
|
||||
|
||||
|
||||
def empty_profile_result(status: str = "empty") -> dict[str, str]:
|
||||
return {
|
||||
"profile_json": "",
|
||||
"profile_name": "",
|
||||
"descriptor": "",
|
||||
"saved_path": "",
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def apply_character_profile_overrides(
|
||||
profile: dict[str, Any],
|
||||
override_subject_type: str = "",
|
||||
override_age: str = "",
|
||||
override_body: str = "",
|
||||
override_body_phrase: str = "",
|
||||
override_skin: str = "",
|
||||
override_hair: str = "",
|
||||
override_eyes: str = "",
|
||||
override_figure: str = "",
|
||||
override_descriptor_detail: str = "",
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(profile)
|
||||
subject_type = str(override_subject_type or "").strip()
|
||||
if subject_type in ("woman", "man"):
|
||||
updated["subject_type"] = subject_type
|
||||
updated["subject"] = subject_type
|
||||
updated["subject_phrase"] = subject_type
|
||||
for key, value in (
|
||||
("age", override_age),
|
||||
("body", override_body),
|
||||
("body_phrase", override_body_phrase),
|
||||
("skin", override_skin),
|
||||
("hair", override_hair),
|
||||
("eyes", override_eyes),
|
||||
("figure", override_figure),
|
||||
):
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
updated[key] = text
|
||||
descriptor_detail = str(override_descriptor_detail or "").strip()
|
||||
if descriptor_detail and descriptor_detail != "keep_profile":
|
||||
updated["descriptor_detail"] = character_policy.normalize_descriptor_detail(descriptor_detail)
|
||||
if not str(updated.get("body_phrase") or "").strip():
|
||||
updated["body_phrase"] = body_phrase(updated.get("body"), updated.get("figure"))
|
||||
updated["descriptor"] = character_profile_descriptor(updated)
|
||||
return updated
|
||||
|
||||
|
||||
def load_character_profile_json(
|
||||
profile_name: str = "",
|
||||
fallback_profile_json: str | dict[str, Any] | None = "",
|
||||
enabled: bool = True,
|
||||
delete_now: bool = False,
|
||||
rename_now: bool = False,
|
||||
rename_to: str = "",
|
||||
override_subject_type: str = "",
|
||||
override_age: str = "",
|
||||
override_body: str = "",
|
||||
override_body_phrase: str = "",
|
||||
override_skin: str = "",
|
||||
override_hair: str = "",
|
||||
override_eyes: str = "",
|
||||
override_figure: str = "",
|
||||
override_descriptor_detail: str = "",
|
||||
) -> dict[str, str]:
|
||||
if not enabled:
|
||||
return empty_profile_result("disabled")
|
||||
if delete_now and rename_now:
|
||||
return empty_profile_result("choose_delete_or_rename")
|
||||
|
||||
raw_profile = load_json_object(fallback_profile_json, "fallback_profile_json")
|
||||
saved_path = ""
|
||||
if profile_name and profile_name != "manual":
|
||||
path = profile_path(profile_name)
|
||||
if delete_now:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return empty_profile_result(f"deleted:{path.stem}")
|
||||
return empty_profile_result(f"delete_missing:{safe_profile_name(profile_name)}")
|
||||
if rename_now:
|
||||
new_name = safe_profile_name(rename_to)
|
||||
if not rename_to.strip():
|
||||
return empty_profile_result("rename_missing_name")
|
||||
if not path.exists():
|
||||
return empty_profile_result(f"rename_missing:{safe_profile_name(profile_name)}")
|
||||
target = profile_path(new_name)
|
||||
if target.exists() and target != path:
|
||||
return empty_profile_result(f"rename_target_exists:{target.stem}")
|
||||
raw_profile = load_json_object(path.read_text(encoding="utf-8"), "character_profile")
|
||||
profile = normalize_character_profile(raw_profile, new_name)
|
||||
target.write_text(json.dumps(profile, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
if target != path:
|
||||
path.unlink()
|
||||
return {
|
||||
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
||||
"profile_name": profile["profile_name"],
|
||||
"descriptor": profile["descriptor"],
|
||||
"saved_path": str(target),
|
||||
"status": f"renamed:{path.stem}->{target.stem}",
|
||||
}
|
||||
if path.exists():
|
||||
raw_profile = load_json_object(path.read_text(encoding="utf-8"), "character_profile")
|
||||
saved_path = str(path)
|
||||
if not raw_profile:
|
||||
return empty_profile_result("empty")
|
||||
profile = normalize_character_profile(raw_profile, profile_name or raw_profile.get("profile_name", ""))
|
||||
profile = apply_character_profile_overrides(
|
||||
profile,
|
||||
override_subject_type=override_subject_type,
|
||||
override_age=override_age,
|
||||
override_body=override_body,
|
||||
override_body_phrase=override_body_phrase,
|
||||
override_skin=override_skin,
|
||||
override_hair=override_hair,
|
||||
override_eyes=override_eyes,
|
||||
override_figure=override_figure,
|
||||
override_descriptor_detail=override_descriptor_detail,
|
||||
)
|
||||
return {
|
||||
"profile_json": json.dumps(profile, ensure_ascii=True, sort_keys=True),
|
||||
"profile_name": profile["profile_name"],
|
||||
"descriptor": profile["descriptor"],
|
||||
"saved_path": saved_path,
|
||||
"status": "loaded" if saved_path else "fallback",
|
||||
}
|
||||
|
||||
|
||||
def parse_character_profile(character_profile: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = load_json_object(character_profile, "character_profile")
|
||||
if not raw:
|
||||
return {}
|
||||
if raw.get("profile_type") == "character" or any(key in raw for key in ("age", "age_band", "skin", "hair", "eyes")):
|
||||
return normalize_character_profile(raw, str(raw.get("profile_name") or ""))
|
||||
return {}
|
||||
|
||||
|
||||
def apply_character_profile_to_context(
|
||||
context: dict[str, Any],
|
||||
character_profile: str | dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||
profile = parse_character_profile(character_profile)
|
||||
if not profile:
|
||||
return context, {}, "none"
|
||||
if context.get("subject_type") not in ("woman", "man"):
|
||||
return context, profile, "skipped_non_single_subject"
|
||||
if profile["subject_type"] != context.get("subject_type"):
|
||||
return context, profile, "skipped_subject_mismatch"
|
||||
updated = dict(context)
|
||||
for key in (
|
||||
"subject_type",
|
||||
"subject",
|
||||
"subject_phrase",
|
||||
"age",
|
||||
"body",
|
||||
"body_phrase",
|
||||
"skin",
|
||||
"hair",
|
||||
"eyes",
|
||||
"figure",
|
||||
"descriptor_detail",
|
||||
):
|
||||
value = profile.get(key)
|
||||
if value:
|
||||
updated[key] = value
|
||||
updated["subject"] = profile["subject_type"]
|
||||
updated["subject_phrase"] = profile["subject_type"]
|
||||
return updated, profile, "applied"
|
||||
|
||||
|
||||
_body_phrase = body_phrase
|
||||
_safe_profile_name = safe_profile_name
|
||||
_profile_path = profile_path
|
||||
_load_json_object = load_json_object
|
||||
_parse_character_manual_config = parse_character_manual_config
|
||||
_character_manual_summary = character_manual_summary
|
||||
_descriptor_detail_for_subject = descriptor_detail_for_subject
|
||||
_descriptor_from_parts = descriptor_from_parts
|
||||
_row_from_profile_metadata = row_from_profile_metadata
|
||||
_character_profile_descriptor = character_profile_descriptor
|
||||
_normalize_character_profile = normalize_character_profile
|
||||
_empty_profile_result = empty_profile_result
|
||||
_apply_character_profile_overrides = apply_character_profile_overrides
|
||||
_parse_character_profile = parse_character_profile
|
||||
_apply_character_profile_to_context = apply_character_profile_to_context
|
||||
@@ -0,0 +1,355 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import character_config as character_policy
|
||||
from . import character_profile as character_profile_policy
|
||||
from . import filter_config as filter_policy
|
||||
from . import pov_policy
|
||||
from . import seed_config as seed_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import character_config as character_policy
|
||||
import character_profile as character_profile_policy
|
||||
import filter_config as filter_policy
|
||||
import pov_policy
|
||||
import seed_config as seed_policy
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(min_value, min(max_value, number))
|
||||
|
||||
|
||||
def normalize_slot_expression_intensity(value: Any) -> float:
|
||||
try:
|
||||
intensity = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return -1.0
|
||||
if intensity < 0:
|
||||
return -1.0
|
||||
return _clamped_float(intensity, 0.5)
|
||||
|
||||
|
||||
def slot_expression_enabled(slot: dict[str, Any] | None) -> bool:
|
||||
if not slot:
|
||||
return True
|
||||
return not _is_false(slot.get("expression_enabled", True))
|
||||
|
||||
|
||||
def slot_expression_intensity(slot: dict[str, Any] | None) -> float | None:
|
||||
if not slot or not slot_expression_enabled(slot):
|
||||
return None
|
||||
intensity = normalize_slot_expression_intensity(slot.get("expression_intensity"))
|
||||
return intensity if intensity >= 0 else None
|
||||
|
||||
|
||||
def slot_expression_intensity_for_phase(slot: dict[str, Any] | None, phase: str = "") -> float | None:
|
||||
if not slot or not slot_expression_enabled(slot):
|
||||
return None
|
||||
phase_key = f"{phase}_expression_intensity" if phase in ("softcore", "hardcore") else ""
|
||||
if phase_key:
|
||||
intensity = normalize_slot_expression_intensity(slot.get(phase_key))
|
||||
if intensity >= 0:
|
||||
return intensity
|
||||
return slot_expression_intensity(slot)
|
||||
|
||||
|
||||
def normalize_slot_seed(value: Any) -> int:
|
||||
return character_policy.normalize_slot_seed(value)
|
||||
|
||||
|
||||
def slot_seed(slot: dict[str, Any] | None) -> int:
|
||||
if not slot:
|
||||
return -1
|
||||
return normalize_slot_seed(slot.get("slot_seed"))
|
||||
|
||||
|
||||
def slot_seeded_rng(slot: dict[str, Any] | None, salt: int) -> random.Random | None:
|
||||
seed = slot_seed(slot)
|
||||
if seed < 0:
|
||||
return None
|
||||
return random.Random(seed_policy.row_seed(seed, 1, salt))
|
||||
|
||||
|
||||
def slot_context_rng(slot: dict[str, Any], fallback_rng: random.Random) -> random.Random:
|
||||
return slot_seeded_rng(slot, 701) or fallback_rng
|
||||
|
||||
|
||||
def slot_effective_figure(
|
||||
slot: dict[str, Any],
|
||||
subject_type: str,
|
||||
fallback_figure: str,
|
||||
) -> str:
|
||||
raw_figure = str(slot.get("figure") or "random").strip()
|
||||
if raw_figure in ("curvy", "balanced", "bombshell"):
|
||||
return raw_figure
|
||||
seeded_rng = slot_seeded_rng(slot, 709)
|
||||
if subject_type == "woman" and seeded_rng is not None:
|
||||
options = ["curvy", "balanced", "bombshell"]
|
||||
return options[seeded_rng.randrange(len(options))]
|
||||
return fallback_figure
|
||||
|
||||
|
||||
def slot_manual_or_choice(choice: str, manual_value: str) -> str:
|
||||
choice = str(choice or "").strip()
|
||||
manual_value = str(manual_value or "").strip()
|
||||
if choice == "manual":
|
||||
return manual_value or "random"
|
||||
if choice.lower() in character_policy.CHARACTER_RANDOM_TOKENS:
|
||||
return "random"
|
||||
return choice
|
||||
|
||||
|
||||
def normalize_slot_ethnicity(value: Any) -> str:
|
||||
return filter_policy.normalize_ethnicity_filter(value, "random", allow_random=True)
|
||||
|
||||
|
||||
def normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
subject_type = str(slot.get("subject_type") or slot.get("subject") or "").strip().lower()
|
||||
if subject_type not in ("woman", "man"):
|
||||
subject_type = "woman"
|
||||
label = str(slot.get("label") or slot.get("label_mode") or "auto_chain").strip()
|
||||
label = label.replace("Woman ", "").replace("Man ", "").strip().upper()
|
||||
if label == "AUTO_CHAIN":
|
||||
label = "auto_chain"
|
||||
if label not in character_policy.CHARACTER_LABEL_CHOICES:
|
||||
label = "auto_chain"
|
||||
|
||||
manual_config = character_profile_policy.parse_character_manual_config(slot.get("manual") or slot.get("manual_config"))
|
||||
|
||||
raw_age = str(slot.get("age") or "random")
|
||||
raw_manual_age = str(slot.get("manual_age") or "").strip()
|
||||
if not raw_manual_age and manual_config.get("manual_age"):
|
||||
raw_manual_age = manual_config["manual_age"]
|
||||
if raw_age.lower() in character_policy.CHARACTER_RANDOM_TOKENS:
|
||||
raw_age = "manual"
|
||||
age = slot_manual_or_choice(raw_age, raw_manual_age)
|
||||
|
||||
raw_body = str(slot.get("body") or "random")
|
||||
raw_manual_body = str(slot.get("manual_body") or "").strip()
|
||||
if not raw_manual_body and manual_config.get("manual_body"):
|
||||
raw_manual_body = manual_config["manual_body"]
|
||||
if raw_body.lower() in character_policy.CHARACTER_RANDOM_TOKENS:
|
||||
raw_body = "manual"
|
||||
body = slot_manual_or_choice(raw_body, raw_manual_body)
|
||||
figure = str(slot.get("figure") or "random").strip()
|
||||
if figure not in character_policy.CHARACTER_FIGURE_CHOICES:
|
||||
figure = "random"
|
||||
|
||||
def manual_fallback(field: str) -> str:
|
||||
direct = character_policy.slot_value(slot.get(field))
|
||||
return direct or manual_config.get(field, "")
|
||||
|
||||
normalized = {
|
||||
"profile_type": "character_slot",
|
||||
"subject_type": subject_type,
|
||||
"label": label,
|
||||
"slot_seed": normalize_slot_seed(slot.get("slot_seed")),
|
||||
"age": age,
|
||||
"ethnicity": normalize_slot_ethnicity(slot.get("ethnicity")),
|
||||
"figure": figure,
|
||||
"body": body,
|
||||
"body_phrase": manual_fallback("body_phrase"),
|
||||
"skin": manual_fallback("skin"),
|
||||
"hair": manual_fallback("hair"),
|
||||
"manual": manual_config,
|
||||
"characteristics": (
|
||||
slot.get("characteristics")
|
||||
if isinstance(slot.get("characteristics"), dict)
|
||||
else character_policy.slot_value(slot.get("characteristics") or slot.get("characteristics_config"))
|
||||
),
|
||||
"hair_config": (
|
||||
slot.get("hair_config")
|
||||
if isinstance(slot.get("hair_config"), dict)
|
||||
else character_policy.slot_value(slot.get("hair_config"))
|
||||
),
|
||||
"hair_color": character_policy.normalize_hair_choice(slot.get("hair_color"), character_policy.CHARACTER_HAIR_COLOR_CHOICES),
|
||||
"hair_length": character_policy.normalize_hair_choice(
|
||||
slot.get("hair_length"),
|
||||
character_policy.CHARACTER_HAIR_LENGTH_CHOICES,
|
||||
),
|
||||
"hair_style": character_policy.normalize_hair_choice(slot.get("hair_style"), character_policy.CHARACTER_HAIR_STYLE_CHOICES),
|
||||
"eyes": manual_fallback("eyes"),
|
||||
"descriptor_detail": character_policy.normalize_descriptor_detail(slot.get("descriptor_detail")),
|
||||
"presence_mode": character_policy.normalize_presence_mode(slot.get("presence_mode"), subject_type),
|
||||
"softcore_outfit": manual_fallback("softcore_outfit"),
|
||||
"hardcore_clothing": (
|
||||
character_policy.slot_value(slot.get("hardcore_clothing") or slot.get("hardcore_outfit"))
|
||||
or manual_config.get("hardcore_clothing", "")
|
||||
),
|
||||
"expression_enabled": not _is_false(slot.get("expression_enabled", True)),
|
||||
"expression_intensity": normalize_slot_expression_intensity(slot.get("expression_intensity")),
|
||||
"softcore_expression_intensity": normalize_slot_expression_intensity(slot.get("softcore_expression_intensity")),
|
||||
"hardcore_expression_intensity": normalize_slot_expression_intensity(slot.get("hardcore_expression_intensity")),
|
||||
}
|
||||
normalized["summary"] = character_slot_summary(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_character_cast(character_cast: str | dict[str, Any] | list[Any] | None) -> list[dict[str, Any]]:
|
||||
if not character_cast:
|
||||
return []
|
||||
if isinstance(character_cast, list):
|
||||
raw = character_cast
|
||||
elif isinstance(character_cast, dict):
|
||||
raw = character_cast
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(character_cast))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid character_cast JSON: {exc}") from exc
|
||||
|
||||
if isinstance(raw, list):
|
||||
slots = raw
|
||||
elif isinstance(raw, dict) and isinstance(raw.get("slots"), list):
|
||||
slots = raw["slots"]
|
||||
elif isinstance(raw, dict) and raw.get("profile_type") == "character_slot":
|
||||
slots = [raw]
|
||||
elif isinstance(raw, dict) and raw.get("subject_type") in ("woman", "man"):
|
||||
slots = [raw]
|
||||
else:
|
||||
return []
|
||||
return [normalize_character_slot(slot) for slot in slots if isinstance(slot, dict)]
|
||||
|
||||
|
||||
def character_slot_summary(slot: dict[str, Any]) -> str:
|
||||
subject = str(slot.get("subject_type") or "woman")
|
||||
label = str(slot.get("label") or "auto_chain")
|
||||
label_text = "nearest free label" if label == "auto_chain" else f"{subject.capitalize()} {label}"
|
||||
parts = [
|
||||
subject,
|
||||
label_text,
|
||||
f"seed={slot.get('slot_seed')}" if slot_seed(slot) >= 0 else "",
|
||||
f"age={slot.get('age', 'random')}",
|
||||
f"ethnicity={slot.get('ethnicity', 'random')}",
|
||||
f"figure={slot.get('figure', 'random')}",
|
||||
f"body={slot.get('body', 'random')}",
|
||||
f"detail={slot.get('descriptor_detail', 'auto')}",
|
||||
]
|
||||
parts = [part for part in parts if part]
|
||||
if pov_policy.slot_is_pov(slot):
|
||||
parts.append("presence=pov")
|
||||
if not slot_expression_enabled(slot):
|
||||
parts.append("expression=disabled")
|
||||
else:
|
||||
expression_intensity = slot_expression_intensity(slot)
|
||||
if expression_intensity is not None:
|
||||
parts.append(f"expression={expression_intensity:.2f}")
|
||||
softcore_expression_intensity = slot_expression_intensity_for_phase(slot, "softcore")
|
||||
hardcore_expression_intensity = slot_expression_intensity_for_phase(slot, "hardcore")
|
||||
if softcore_expression_intensity is not None and softcore_expression_intensity != expression_intensity:
|
||||
parts.append(f"soft_expr={softcore_expression_intensity:.2f}")
|
||||
if hardcore_expression_intensity is not None and hardcore_expression_intensity != expression_intensity:
|
||||
parts.append(f"hard_expr={hardcore_expression_intensity:.2f}")
|
||||
if slot.get("softcore_outfit"):
|
||||
parts.append(f"soft_outfit={slot['softcore_outfit']}")
|
||||
if slot.get("hardcore_clothing"):
|
||||
parts.append(f"hard_clothing={slot['hardcore_clothing']}")
|
||||
characteristics = character_policy.parse_characteristics_config(slot.get("characteristics"))
|
||||
characteristics_summary = character_policy.characteristics_summary(characteristics)
|
||||
if characteristics_summary != "characteristics unrestricted":
|
||||
parts.append(f"characteristics={characteristics_summary}")
|
||||
hair_config = character_policy.parse_hair_config(slot.get("hair_config"))
|
||||
hair_config_summary = character_policy.hair_config_summary(hair_config)
|
||||
if hair_config_summary != "hair unrestricted":
|
||||
parts.append(f"hair={hair_config_summary}")
|
||||
for key in ("hair_color", "hair_length", "hair_style"):
|
||||
value = slot.get(key)
|
||||
if value and value != "random":
|
||||
parts.append(f"{key}={value}")
|
||||
for key in ("body_phrase", "skin", "hair", "eyes"):
|
||||
value = slot.get(key)
|
||||
if value:
|
||||
parts.append(f"{key}={value}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def build_character_slot_json(
|
||||
subject_type: str = "woman",
|
||||
label: str = "auto_chain",
|
||||
slot_seed: int = -1,
|
||||
age: str = "random",
|
||||
manual_age: str = "",
|
||||
manual: str | dict[str, Any] | None = "",
|
||||
ethnicity: str = "random",
|
||||
figure: str = "random",
|
||||
body: str = "random",
|
||||
manual_body: str = "",
|
||||
body_phrase: str = "",
|
||||
skin: str = "",
|
||||
hair: str = "",
|
||||
characteristics: str | dict[str, Any] | None = "",
|
||||
hair_config: str | dict[str, Any] | None = "",
|
||||
hair_color: str = "random",
|
||||
hair_length: str = "random",
|
||||
hair_style: str = "random",
|
||||
eyes: str = "",
|
||||
descriptor_detail: str = "auto",
|
||||
expression_enabled: bool = True,
|
||||
expression_intensity: float = -1.0,
|
||||
enabled: bool = True,
|
||||
character_cast: str | dict[str, Any] | list[Any] | None = "",
|
||||
presence_mode: str = "visible",
|
||||
softcore_expression_intensity: float = -1.0,
|
||||
hardcore_expression_intensity: float = -1.0,
|
||||
softcore_outfit: str = "",
|
||||
hardcore_clothing: str = "",
|
||||
) -> dict[str, str]:
|
||||
existing_slots = parse_character_cast(character_cast)
|
||||
slot = normalize_character_slot(
|
||||
{
|
||||
"subject_type": subject_type,
|
||||
"label": label,
|
||||
"slot_seed": slot_seed,
|
||||
"age": age,
|
||||
"manual_age": manual_age,
|
||||
"manual": manual,
|
||||
"ethnicity": ethnicity,
|
||||
"figure": figure,
|
||||
"body": body,
|
||||
"manual_body": manual_body,
|
||||
"body_phrase": body_phrase,
|
||||
"skin": skin,
|
||||
"hair": hair,
|
||||
"characteristics": characteristics,
|
||||
"hair_config": hair_config,
|
||||
"hair_color": hair_color,
|
||||
"hair_length": hair_length,
|
||||
"hair_style": hair_style,
|
||||
"eyes": eyes,
|
||||
"descriptor_detail": descriptor_detail,
|
||||
"presence_mode": presence_mode,
|
||||
"softcore_outfit": softcore_outfit,
|
||||
"hardcore_clothing": hardcore_clothing,
|
||||
"expression_enabled": expression_enabled,
|
||||
"expression_intensity": expression_intensity,
|
||||
"softcore_expression_intensity": softcore_expression_intensity,
|
||||
"hardcore_expression_intensity": hardcore_expression_intensity,
|
||||
}
|
||||
)
|
||||
slots = existing_slots + ([slot] if enabled else [])
|
||||
cast = {
|
||||
"profile_type": "character_cast",
|
||||
"version": 1,
|
||||
"slots": slots,
|
||||
}
|
||||
return {
|
||||
"character_cast": json.dumps(cast, ensure_ascii=True, sort_keys=True),
|
||||
"character_slot": json.dumps(slot, ensure_ascii=True, sort_keys=True) if enabled else "",
|
||||
"summary": slot["summary"] if enabled else "disabled",
|
||||
"status": f"{len(slots)} slot(s)",
|
||||
}
|
||||
@@ -7,7 +7,8 @@ routing map in `docs/prompt-pool-routing-map.md`.
|
||||
|
||||
The current branch adds two major surfaces:
|
||||
|
||||
- `SxCP Krea2 Resolution Selector` in `__init__.py`, with README notes.
|
||||
- `SxCP Krea2 Resolution Selector` in `node_seed_resolution.py`, with README
|
||||
notes.
|
||||
- Expanded hardcore interaction/manual/action pools in
|
||||
`categories/sexual_poses.json`,
|
||||
`categories/expression_composition_pools.json`, `prompt_builder.py`, and
|
||||
@@ -20,6 +21,31 @@ The map audit currently sees:
|
||||
- 23 expression pools.
|
||||
- 24 composition pools.
|
||||
- A new Krea2 resolution node with width/height/API aspect outputs.
|
||||
- Registered route policy validation, so action/position families stay covered
|
||||
by SDXL family tags, caption labels, and SDXL incompatibility-filter keys.
|
||||
- Route simulation family coverage, so representative generated rows exercise
|
||||
every registered action and position family except documented special cases
|
||||
covered by dedicated smoke fixtures.
|
||||
- Pair seed simulation, so Insta/OF soft/hard metadata and formatter outputs
|
||||
prove locked determinism and person/scene/content/pose/expression/
|
||||
composition reroll behavior.
|
||||
- Formatter route traces expose selected metadata fields, so Krea2, SDXL, and
|
||||
caption outputs can be debugged by category, action/position family, selected
|
||||
pair side, scene profile, position keys, and POV labels instead of only
|
||||
proving that a metadata branch was used.
|
||||
- Insta/OF side-target training captions no longer prepend shared cast
|
||||
descriptors when the selected side row already emits its own cast prose, and
|
||||
route simulation flags repeated cast descriptors.
|
||||
- Route simulation now has an opt-in multi-seed sweep, and the smoke suite runs
|
||||
a three-seed sweep so representative route/noise checks are not proven by one
|
||||
lucky seed only.
|
||||
- Route simulation now emits a `quality` summary that groups route health by
|
||||
target, action family, and position family, separates route issues from
|
||||
coverage/seed-check issues, buckets issue types, and reports weakest cases so
|
||||
future prompt-logic passes can target the worst path first.
|
||||
- Map audit now fails when a registered ComfyUI node display name is missing
|
||||
from the route map or README, so utility nodes cannot silently drift out of
|
||||
user-facing documentation.
|
||||
|
||||
## Architectural Finding
|
||||
|
||||
@@ -52,12 +78,65 @@ It should only handle route-agnostic cleanup:
|
||||
- empty field-label removal;
|
||||
- repeated trigger prefix cleanup;
|
||||
- duplicate comma-list item removal;
|
||||
- route-agnostic negative-prompt merge/dedupe;
|
||||
- adjacent duplicate sentence cleanup;
|
||||
- simple dangling connector cleanup.
|
||||
|
||||
It must not make semantic decisions such as sexual action positioning, POV
|
||||
geometry, clothing state, or model-specific tag weighting. Those stay in the
|
||||
route-specific owner.
|
||||
route-specific owner. It also preserves ordinary words such as `composition`
|
||||
inside normal sentences; empty field-label cleanup is limited to standalone
|
||||
labels.
|
||||
|
||||
Formatter input/fallback parsing now has one home:
|
||||
|
||||
- `formatter_input.py`
|
||||
|
||||
It owns route-neutral parsing shared by Krea2, SDXL, and natural-caption
|
||||
routes:
|
||||
|
||||
- input-hint choice lists and normalization for `auto`, `metadata_json`, and
|
||||
route-specific text modes;
|
||||
- whitespace and punctuation normalization before formatter parsing;
|
||||
- JSON row detection from `metadata_json` or source text;
|
||||
- trigger-prefix stripping with route-specific trigger candidate lists;
|
||||
- `Avoid:` positive/negative splitting for fallback text;
|
||||
- the shared prompt field-label inventory and extraction such as `Setting:`,
|
||||
`Sexual scene:`, `Camera control:`, or `Composition:`;
|
||||
- fallback field-label stripping for tag/text routes that need label-free body
|
||||
text;
|
||||
- row-value fallback from metadata fields to labeled prompt text.
|
||||
|
||||
It must not make formatter-style decisions. Krea prose, SDXL tags, and training
|
||||
caption sentence shape stay in their formatter modules.
|
||||
|
||||
Formatter detail-level handling now has one home:
|
||||
|
||||
- `formatter_detail.py`
|
||||
|
||||
It owns route-neutral prose detail levels, node choice lists, normalization, and
|
||||
the concise/balanced/dense inclusion gate used by Krea2 and natural-caption
|
||||
routes. It must not own route-specific style controls such as Krea photographic
|
||||
mode or caption style-tail policy.
|
||||
|
||||
Formatter target handling now has one home:
|
||||
|
||||
- `formatter_target.py`
|
||||
|
||||
It owns route-neutral target normalization for `auto`, `single`, `softcore`,
|
||||
and `hardcore`, including node choice lists and pair-side semantics.
|
||||
Single-output formatters select the softcore side for pair `auto`/`single`
|
||||
targets, while caption pair routing can still include both sides for combined
|
||||
training captions.
|
||||
|
||||
Shared hardcore phrase cleanup now has one home:
|
||||
|
||||
- `hardcore_text_cleanup.py`
|
||||
|
||||
It owns environment-anchor normalization used by both prompt generation and
|
||||
Krea formatting, including malformed surface joins and bed/sheet/couch anchors
|
||||
that should become model-neutral body-support language. It must stay
|
||||
route-neutral: no Krea prose, no SDXL tags, and no category selection logic.
|
||||
|
||||
Current integration points:
|
||||
|
||||
@@ -84,30 +163,227 @@ Keep here:
|
||||
|
||||
Move or isolate later:
|
||||
|
||||
- role graph generation for hardcore interaction categories into a dedicated
|
||||
module, for example `hardcore_role_graphs.py`;
|
||||
- camera-scene adapters into `scene_camera_adapters.py`;
|
||||
- category-library loading and inheritance helpers into `category_library.py`.
|
||||
- pair assembly helpers that still live in `prompt_builder.py`.
|
||||
|
||||
Already isolated:
|
||||
|
||||
- single-prompt builder orchestration, including input normalization, seed-axis
|
||||
setup, built-in/custom row routing, legacy location/composition handling,
|
||||
camera application, and final prompt-row normalization, lives in
|
||||
`builder_prompt_route.py`; `prompt_builder.py` keeps the public wrapper.
|
||||
- config-driven prompt-builder request parsing, helper-node config mapping, and
|
||||
direct `build_prompt` kwarg assembly live in `builder_config_route.py`;
|
||||
`prompt_builder.py` keeps the public wrapper.
|
||||
- JSON category loading, subcategory normalization, named scene/expression/
|
||||
composition pool loading, cast compatibility filtering, exact subcategory
|
||||
lookup, and inheritance-based pool merging live in `category_library.py`.
|
||||
- JSON `pool_extensions`, legacy pool patching, built-in category choice lists,
|
||||
and category/subcategory UI choices live in `category_extensions.py`.
|
||||
- object-style item-template metadata extraction, action/position family
|
||||
normalization, position-key normalization, and metadata audit errors live in
|
||||
`category_template_metadata.py`.
|
||||
- row item selection, weighted item/pair choice, item-template axis filling,
|
||||
and oral/outercourse/anal axis compatibility filters live in `row_item.py`;
|
||||
`prompt_builder.py` keeps public delegate wrappers.
|
||||
- outercourse action-kind classification for boobjob, testicle-sucking,
|
||||
penis-licking, handjob, and footjob lives in `outercourse_action_policy.py`
|
||||
and is shared by row item filtering, role graphs, and Krea action cleanup.
|
||||
- row category/subcategory/item route resolution lives in
|
||||
`row_category_route.py` behind `CategoryItemRoute`, covering hardcore
|
||||
position-category filtering, cast-count adjustment, pose-vs-content seed-axis
|
||||
choice, item metadata collection, legacy dict compatibility, and
|
||||
pose-category item sanitizing; `prompt_builder.py` keeps public delegate
|
||||
wrappers.
|
||||
- row prompt/caption template selection, safe formatting, default prompt
|
||||
templates, configured-cast descriptor insertion, and POV directive insertion
|
||||
live in `row_rendering.py`; `prompt_builder.py` keeps compatibility aliases.
|
||||
- row action/position route metadata resolution lives in
|
||||
`row_route_metadata.py` behind `ActionPositionRoute`, covering template
|
||||
metadata precedence, inferred position-key merging, legacy dict
|
||||
compatibility, and source action-family fallback; `prompt_builder.py` keeps
|
||||
public delegate wrappers.
|
||||
- built-in legacy row generation, auto-weighted/auto-full selection, row mode
|
||||
randomization, ratio clamps, and expression-intensity randomization live in
|
||||
`row_generation.py`; `prompt_builder.py` keeps public delegate wrappers.
|
||||
- category/cast route preset schemas, config JSON builders, choice lists, and
|
||||
parsers live in `category_cast_config.py`; `prompt_builder.py` keeps public
|
||||
delegate wrappers for existing nodes and tests.
|
||||
- generation-time cast count phrases, configured-cast context metadata,
|
||||
character-slot label assignment, scene-kind labels, cast-summary wording, and
|
||||
couple count normalization live in `cast_context.py`; `prompt_builder.py`
|
||||
keeps delegate wrappers where existing generation paths still call the old
|
||||
helper names.
|
||||
- row subject-context routing for single, couple, configured-cast, group, and
|
||||
layout subjects lives in `subject_context.py`; it combines appearance policy,
|
||||
cast metadata, and generator subject pools behind one row-facing entry point.
|
||||
- row subject route orchestration, character slot/profile precedence,
|
||||
configured-cast POV labels, visible cast descriptor collection, and
|
||||
descriptor prompt cleanup live in `row_subject_route.py`;
|
||||
`prompt_builder.py` keeps a public delegate wrapper.
|
||||
- ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter
|
||||
parsing, and ethnicity normalization live in `filter_config.py`; character
|
||||
routes and builder filters use `prompt_builder.py` delegate wrappers.
|
||||
- character choice lists, descriptor detail/presence/slot-seed normalization,
|
||||
characteristic-list JSON builders/parsers, eye labels, hair config
|
||||
builders/parsers, and hair phrase helpers live in `character_config.py`;
|
||||
`prompt_builder.py` keeps public delegate wrappers.
|
||||
- character slot JSON construction, character-cast parsing, slot normalization,
|
||||
slot summary text, slot expression override policy, slot seed helpers, and
|
||||
slot figure/ethnicity normalization live in `character_slot.py`;
|
||||
`prompt_builder.py` keeps public delegate wrappers.
|
||||
- generation-time subject appearance selection, normalized-slot context
|
||||
resolution, slot hair/outfit/clothing selection, character-context row
|
||||
application, and character-slot-to-profile-row conversion live in
|
||||
`character_appearance.py`; `prompt_builder.py` keeps public delegate wrappers.
|
||||
- character manual-detail config, profile name/path policy, profile JSON
|
||||
normalization, descriptor assembly, save/load/rename/delete operations,
|
||||
fallback profile loading, and context override application live in
|
||||
`character_profile.py`; `prompt_builder.py` only bridges generated slot rows
|
||||
into profile saves.
|
||||
- generation profile presets, override normalization, trigger policy, and
|
||||
profile config parsing live in `generation_profile_config.py`;
|
||||
`prompt_builder.py` keeps public delegate wrappers.
|
||||
- location/composition config presets, themed location packs, custom
|
||||
location/composition entry parsing, merge behavior, and config parsing live
|
||||
in `location_config.py`; built-in row location/composition config
|
||||
application, source metadata, and prompt/caption rewrites live in
|
||||
`row_location.py`.
|
||||
- row scene/expression/pose/composition pool routing, category inheritance,
|
||||
runtime location/composition pool overrides, and generator fallback pool
|
||||
selection live in `row_pools.py`; `prompt_builder.py` keeps public delegate
|
||||
wrappers.
|
||||
- row scene/pose/expression/composition axis selection lives in
|
||||
`row_prompt_axes.py` behind `PromptAxesRoute`, covering compatible-entry
|
||||
filtering, expression-disabled handling, per-character expression promotion,
|
||||
legacy dict compatibility, POV composition adaptation, and pose-category
|
||||
environment sanitizing; `prompt_builder.py` keeps public delegate wrappers.
|
||||
- row prompt/caption text-field resolution, prompt/caption template selection,
|
||||
safe formatting, configured-cast descriptor insertion, and POV directive
|
||||
insertion live in `row_rendering.py`; `prompt_builder.py` keeps public
|
||||
delegate wrappers.
|
||||
- row role-graph route sequencing lives in `row_role_graph.py`, covering
|
||||
hardcore source role graph construction, pose-category environment-anchor
|
||||
cleanup, and POV role-graph rewriting before prompt axes and formatter
|
||||
metadata consume the graph.
|
||||
- row expression text cleanup, expression route resolution, expression
|
||||
intensity weighting, character-slot/cast expression override resolution, and
|
||||
per-character expression picking plus action-aware character-expression
|
||||
sanitizing live in `row_expression.py`; `prompt_builder.py` keeps public
|
||||
delegate wrappers.
|
||||
- hardcore position/action-filter choices, selected-position normalization,
|
||||
config JSON builders/parsers, focus-policy toggles, subcategory allow-list
|
||||
policy, position-key detection, category filtering, and item-template/axis
|
||||
filtering live in `hardcore_position_config.py`.
|
||||
- hardcore configured-cast role graph generation lives in
|
||||
`hardcore_role_graphs.py`; row generation reaches it through
|
||||
`row_role_graph.py` after item/axis metadata is selected.
|
||||
- fallback role graph wording lives in `hardcore_role_fallback.py`, covering
|
||||
solo rows, women-only rows, men-only rows, mixed group fallbacks, and support
|
||||
partner sentences.
|
||||
- interaction-style role graph wording lives in `hardcore_role_interaction.py`,
|
||||
covering foreplay, manual stimulation, body worship, clothing transitions,
|
||||
dominant guidance, camera performance, aftercare, and group coordination.
|
||||
- outercourse-specific role graph wording has started moving into action-family
|
||||
modules; `hardcore_role_outercourse.py` owns boobjob, testicle-sucking,
|
||||
penis-licking, handjob, and footjob body geometry, keyed by
|
||||
`outercourse_action_policy.py`.
|
||||
- oral-specific role graph wording lives in `hardcore_role_oral.py`, including
|
||||
direct POV viewer phrasing for kneeling, face-sitting, sixty-nine,
|
||||
edge-supported, side-lying, chair, standing, and reclining oral positions.
|
||||
- penetration-specific role graph wording lives in
|
||||
`hardcore_role_penetration.py`, covering the main vaginal penetration
|
||||
position families while Krea POV rewriting keeps first-person geometry stable.
|
||||
- anal/double-contact role graph wording lives in `hardcore_role_anal.py`,
|
||||
covering rear-entry anal variants and front/back double-contact source
|
||||
geometry.
|
||||
- climax role graph wording lives in `hardcore_role_climax.py`, covering
|
||||
ejaculation aftermath placement for face/body/ass, lap, open-thigh,
|
||||
side-lying, and front/back group layouts.
|
||||
- camera option schema, orbit/Qwen translation, config parsing, camera
|
||||
directive text, and camera caption text live in `camera_config.py`;
|
||||
camera-scene prose and contextual scene composition mutation for coworking,
|
||||
library, and semi-public profiles live in `scene_camera_adapters.py`;
|
||||
row-level camera insertion, subject-kind detection, and POV suppression live
|
||||
in `row_camera.py`.
|
||||
- shared POV slot detection, label merging/filtering, builder-side POV
|
||||
directives, source role-graph viewer replacement, and shared composition
|
||||
cleanup live in `pov_policy.py`; prompt builder and Krea POV routes delegate
|
||||
to it.
|
||||
- shared hardcore environment-anchor cleanup lives in
|
||||
`hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata
|
||||
reaches formatter routes.
|
||||
- shared hardcore action metadata lives in `hardcore_action_metadata.py`; custom
|
||||
rows now emit `action_family`, `position_family`, `position_key`, and
|
||||
`position_keys` so formatter routing and debugging do less keyword guessing.
|
||||
Krea, SDXL, and training-caption routes consume these fields when present.
|
||||
- shared row route metadata readers live in `route_metadata.py`, covering
|
||||
normalized action family, position family/keys, and route-specific formatter
|
||||
hints for Krea, SDXL, and training-caption routes. Position keys are strict
|
||||
by default, while SDXL can opt into legacy unknown key tags for compatibility.
|
||||
- final row and pair text normalization lives in `row_normalization.py`,
|
||||
covering trigger prepending, extra-positive append, negative merge/dedupe,
|
||||
caption-part joining, embedded soft/hard row output synchronization, and row
|
||||
sanitation before metadata leaves generation. It also copies side-specific
|
||||
pair metadata, such as soft partner styling and hardcore clothing/detail
|
||||
state, plus shared cast descriptors, onto the embedded soft/hard rows.
|
||||
- final custom-row assembly now lives in `row_assembly.py` behind
|
||||
`CustomRowAssemblyRequest`, covering render context population,
|
||||
prompt/caption rendering delegation, row-base indexing, row metadata copying,
|
||||
configured-cast count metadata, profile/slot metadata, and
|
||||
disabled-expression cleanup.
|
||||
|
||||
### Pair / Adapter Layer
|
||||
|
||||
Owner today: `build_insta_of_pair`.
|
||||
Owner today: `pair_builder.py`; `prompt_builder.build_insta_of_pair` is the
|
||||
public wrapper used by the node layer.
|
||||
|
||||
Keep here:
|
||||
|
||||
- soft/hard row creation;
|
||||
- continuity policy;
|
||||
- softcore cast policy;
|
||||
- pair-level camera routing;
|
||||
- pair metadata shape.
|
||||
- the public wrapper signature and dependency bridge needed by existing nodes
|
||||
and tests.
|
||||
|
||||
Improve later:
|
||||
Already isolated:
|
||||
|
||||
- make a single pair metadata sanitizer that normalizes `softcore_row`,
|
||||
`hardcore_row`, pair prompts, negatives, captions, and camera fields;
|
||||
- split pair assembly into small functions by phase:
|
||||
`build_soft_row`, `build_hard_row`, `resolve_pair_camera`,
|
||||
`resolve_pair_clothing`, `assemble_pair_metadata`.
|
||||
- Insta/OF option normalization, softcore category/outfit/pose pools, partner
|
||||
outfit pools, clothing-continuity labels, negatives, and hardcore cast count
|
||||
policy, plus hardcore detail-density directive text, live in
|
||||
`pair_options.py`; `prompt_builder.py` keeps public delegate wrappers for
|
||||
existing nodes and tests.
|
||||
- pair route sequencing now lives in `pair_builder.py` behind
|
||||
`InstaPairBuildRequest` and `InstaPairBuildDependencies`, covering
|
||||
option/filter/seed/cast parsing handoff, soft/hard row orchestration, cast
|
||||
context, camera route, clothing route, and final output assembly delegation.
|
||||
- soft/hard row creation lives in `pair_rows.py` behind `InstaPairRowsRoute`,
|
||||
including softcore expression override resolution, Woman A slot context
|
||||
application, soft outfit/pose overrides, POV row fields, hardcore row
|
||||
creation, and legacy dict compatibility.
|
||||
- pair-level cast/display context lives in `pair_cast.py`, including descriptor
|
||||
prose, descriptor-entry assembly, shared descriptors, cast-label cleanup,
|
||||
same-cast softcore descriptor text, partner styling, platform and level
|
||||
labels, softcore cast presence text, and hard cast summary text.
|
||||
- shared softcore pair prose for solo/same-cast/POV presence, caption side
|
||||
wording, and creator-shot teaser directives lives in
|
||||
`softcore_text_policy.py`; pair, Krea, and caption routes delegate to it.
|
||||
- pair-level camera routing lives in `pair_camera.py` behind
|
||||
`InstaPairCameraRoute`, including soft/hard camera config selection,
|
||||
same-as-softcore mode, camera-detail override, same-room hard scene
|
||||
continuity, camera-aware composition mutation, POV camera suppression,
|
||||
row/root camera metadata synchronization, and legacy dict compatibility.
|
||||
- pair-level clothing policy lives in `pair_clothing.py` behind
|
||||
`HardcorePairClothingRoute`, including clothing sentence formatting,
|
||||
body-exposure scene cleanup, action-aware body-access flags, conflicting
|
||||
outfit-piece cleanup, default visible-men clothing, character-clothing
|
||||
override handling, hardcore clothing continuity, final root clothing-state
|
||||
assembly, and legacy dict compatibility.
|
||||
- final pair output assembly lives in `pair_output.py`, including soft/hard
|
||||
prompt strings, trigger preservation, negatives, captions, and root metadata
|
||||
shape; the final cleanup step is delegated to `row_normalization.py`.
|
||||
Embedded soft/hard rows are synchronized to the final pair prompt, caption,
|
||||
and negative outputs during normalization so serialized pair metadata does
|
||||
not carry stale standalone row text. Side-specific structured fields are
|
||||
synchronized there too, including soft partner styling, hardcore clothing
|
||||
continuity metadata, and shared cast descriptors for same-cast caption and
|
||||
formatter routes.
|
||||
|
||||
### Krea2 Formatter Path
|
||||
|
||||
@@ -116,20 +392,71 @@ Owner: `krea_formatter.py`.
|
||||
Keep here:
|
||||
|
||||
- Krea prose style;
|
||||
- cast prose;
|
||||
- hardcore action sentence rewriting;
|
||||
- POV sentence rewriting;
|
||||
- clothing naturalization;
|
||||
- Krea top-level route orchestration;
|
||||
- camera-scene preservation;
|
||||
- fallback text parsing.
|
||||
|
||||
Already isolated:
|
||||
|
||||
- `krea_format_route.py` owns top-level Krea dispatch, including option
|
||||
normalization, metadata-vs-text input selection, single-vs-pair branching,
|
||||
shared target normalization via `formatter_target.py`, extra
|
||||
positive/negative merging, final prose hygiene, and output shape;
|
||||
`krea_formatter.py` keeps the public wrapper.
|
||||
- `krea_configured_cast_formatter.py` owns normal metadata configured-cast
|
||||
Krea prose assembly behind `KreaConfiguredCastRequest`,
|
||||
`KreaConfiguredCastDependencies`, and `KreaConfiguredCastPrompt`;
|
||||
`krea_formatter.py` keeps configured-cast detection and compatibility
|
||||
wrapper helpers.
|
||||
- `krea_normal_formatter.py` owns normal metadata single/couple/generic Krea
|
||||
prose assembly behind `KreaNormalRowRequest`, `KreaNormalRowDependencies`,
|
||||
and `KreaNormalRowPrompt`; `krea_formatter.py` keeps route selection.
|
||||
- `krea_row_fields.py` owns shared normal-row Krea field extraction for item,
|
||||
scene, pose, expression, composition/source-composition, camera, and style so
|
||||
normal and configured-cast Krea routes cannot drift independently.
|
||||
- `krea_pair_formatter.py` owns Insta/OF pair soft/hard Krea prose assembly
|
||||
behind `KreaPairFormatRequest`, `KreaPairFormatDependencies`, and
|
||||
`KreaPairPrompts`; `krea_formatter.py` keeps the `_insta_pair_to_krea`
|
||||
compatibility wrapper.
|
||||
- `krea_cast.py` owns cast descriptor parsing, cast labels, cast prose, label
|
||||
joining, natural cast descriptor text, and label replacement for formatter
|
||||
routes, including the caption naturalizer's cast metadata path.
|
||||
- `krea_clothing.py` owns clothing-state cleanup and action-aware body-access
|
||||
wording for formatter routes.
|
||||
- `krea_action_context.py` owns shared action-family predicates, axis context
|
||||
text, climax detection, and detail-density normalization used by action and
|
||||
POV formatter routes.
|
||||
- `hardcore_action_metadata.py` owns shared action-family constants,
|
||||
normalization, and inference used by the builder and Krea formatter route.
|
||||
- `pov_policy.py` owns shared POV labels, label filtering, source role-graph
|
||||
viewer replacement, and composition cleanup; `krea_pov.py` owns Krea-specific
|
||||
POV camera support text while delegating shared POV policy.
|
||||
- `krea_detail.py` owns generic detail-clause splitting, deduping, joining, and
|
||||
density limiting for Krea action prose.
|
||||
- `krea_action_positions.py` owns non-POV pose anchors, body-arrangement text,
|
||||
rear-entry detection, and action-position phrasing.
|
||||
- `krea_action_details.py` owns non-climax item/detail cleanup for foreplay,
|
||||
outercourse, oral, penetration, toy/double-contact, and anchor dedupe paths.
|
||||
- `krea_action_climax.py` owns climax-specific role/detail cleanup and aftermath
|
||||
view dedupe.
|
||||
- `krea_action_dispatch.py` owns non-POV role normalization, action-family
|
||||
classification, and family-specific detail cleanup.
|
||||
- `krea_actions.py` owns final non-POV hardcore action sentence assembly.
|
||||
- `krea_pov_actions.py` owns POV hardcore action sentence rewriting,
|
||||
first-person body geometry, and selected-position-axis priority before loose
|
||||
context fallback.
|
||||
- `formatter_input.py` owns shared metadata/source JSON detection, trigger
|
||||
stripping, the shared prompt field-label inventory, prompt-field extraction,
|
||||
`Avoid:` splitting, and row-value fallback for Krea, SDXL, and caption
|
||||
routes.
|
||||
- `route_metadata.py` owns shared row-level action-family, position-family,
|
||||
position-key, and formatter-hint reads so formatter routes do not normalize
|
||||
these fields independently.
|
||||
|
||||
Improve later:
|
||||
|
||||
- split semantic blocks into modules:
|
||||
`krea_cast.py`, `krea_actions.py`, `krea_pov.py`, `krea_clothing.py`;
|
||||
- add route-level smoke fixtures for representative metadata rows;
|
||||
- make `_hardcore_action_sentence` dispatch by action family instead of long
|
||||
conditional chains.
|
||||
- keep adding route-level smoke fixtures when new metadata fields start
|
||||
influencing formatter output;
|
||||
|
||||
### SDXL Formatter Path
|
||||
|
||||
@@ -139,16 +466,38 @@ Keep here:
|
||||
|
||||
- trigger behavior;
|
||||
- style and quality presets;
|
||||
- tag ordering;
|
||||
- weighted explicit tags;
|
||||
- final style/body/quality prompt assembly;
|
||||
- nude-weight setting;
|
||||
- negative-prompt assembly.
|
||||
|
||||
Improve later:
|
||||
Already isolated:
|
||||
|
||||
- move presets into data dictionaries or JSON so adding styles does not require
|
||||
editing formatter logic;
|
||||
- add formatter profiles for Pony, SDXL photo, and flat vector;
|
||||
- make fallback cleanup use the shared field-label inventory.
|
||||
- `sdxl_format_route.py` owns top-level SDXL dispatch, including formatter
|
||||
profile application, shared target normalization via `formatter_target.py`,
|
||||
nude-weight normalization, metadata-vs-text input selection, single-vs-pair
|
||||
branching, final prompt/negative output shape, and fallback routing;
|
||||
`sdxl_formatter.py` keeps the public wrapper.
|
||||
- `sdxl_tag_routes.py` owns normal metadata row tags and Insta/OF pair soft/hard
|
||||
tag extraction behind `SDXLRowTagRequest`, `SDXLPairTagRequest`,
|
||||
`SDXLTagRouteDependencies`, and `SDXLTagRoute`; `sdxl_formatter.py` keeps
|
||||
compatibility wrappers plus final style/quality/trigger assembly.
|
||||
- `sdxl_tag_policy.py` owns SDXL tag splitting, tag-key dedupe, count inference,
|
||||
character descriptor tags, metadata-family hint tags, camera tags,
|
||||
explicit/nude helper tags, and route dependency assembly.
|
||||
- metadata-family tag hint data from `action_family`, `position_family`, and
|
||||
`position_keys` stays in `sdxl_presets.py` and is read by `sdxl_tag_policy.py`.
|
||||
- shared row route metadata reads from `route_metadata.py`.
|
||||
- shared formatter input parsing from `formatter_input.py`.
|
||||
- style presets, quality presets, default negative prompt, and action/position
|
||||
family tag hints from `sdxl_presets.py`.
|
||||
- formatter profiles for manual controls, Pony flat-vector, SDXL photo, and
|
||||
plain flat-vector styles live in `sdxl_presets.py` and are exposed by
|
||||
`SxCP SDXL Formatter`.
|
||||
- fallback field-label cleanup delegates to `formatter_input.py`.
|
||||
|
||||
Improve later:
|
||||
- add route-level fixtures for any new SDXL model profile that needs different
|
||||
tag ordering.
|
||||
|
||||
### Naturalizer Path
|
||||
|
||||
@@ -156,14 +505,40 @@ Owner: `caption_naturalizer.py`.
|
||||
|
||||
Keep here:
|
||||
|
||||
- natural sentence caption assembly;
|
||||
- top-level natural caption orchestration;
|
||||
- training-caption trigger behavior;
|
||||
- style-tail policy.
|
||||
- style-tail policy from `caption_policy.py`.
|
||||
|
||||
Already isolated:
|
||||
|
||||
- `caption_format_route.py` owns top-level caption dispatch, including input
|
||||
hint normalization, shared target normalization via `formatter_target.py`,
|
||||
caption profile application, metadata-vs-text branching, trigger wrapping,
|
||||
final prose hygiene, and method/output shape;
|
||||
`caption_naturalizer.py` keeps the public wrapper.
|
||||
- `caption_metadata_routes.py` owns metadata row natural-language assembly for
|
||||
single, couple, configured-cast, group/layout, and Insta/OF pair routes behind
|
||||
`CaptionMetadataRouteRequest`, `CaptionMetadataRouteDependencies`, and
|
||||
`CaptionMetadataRoute`; `caption_naturalizer.py` keeps compatibility wrappers,
|
||||
profile handling, trigger behavior, and text fallback.
|
||||
- `caption_text_policy.py` owns caption sentence helpers, trigger wrapping,
|
||||
formatter-hint append, row-value fallback wrappers, cast text wrappers,
|
||||
single-caption front parsing, route dependency assembly, and caption metadata
|
||||
helper callbacks used by `caption_metadata_routes.py`.
|
||||
- metadata-family action labels from `action_family` and `position_family` via
|
||||
`caption_policy.py`.
|
||||
- shared row route metadata reads from `route_metadata.py`.
|
||||
- shared formatter input parsing from `formatter_input.py`.
|
||||
- shared cast descriptor parsing and label replacement from `krea_cast.py`.
|
||||
- caption detail-level/style-policy normalization, clothing cleanup, and
|
||||
composition cleanup from `caption_policy.py`.
|
||||
- caption profiles for manual controls, concise training captions, dense
|
||||
training captions, and browsing captions live in `caption_policy.py` and are
|
||||
exposed by `SxCP Caption Naturalizer`.
|
||||
|
||||
Improve later:
|
||||
|
||||
- share more metadata readers with Krea without sharing Krea prose;
|
||||
- add a `caption_profile` option for concise/dense LoRA caption styles.
|
||||
- add more caption profiles if a new training or browsing workflow needs a
|
||||
distinct default.
|
||||
|
||||
### Category JSON Path
|
||||
|
||||
@@ -175,18 +550,27 @@ Keep here:
|
||||
- named scene/expression/composition pools;
|
||||
- item templates and axes;
|
||||
- direct category-specific wording.
|
||||
- optional object-style item templates with route metadata such as
|
||||
`action_family`, `action_type`, `position_family`, `family`, `position_key`,
|
||||
`position_keys`, and `formatter_hint`; string templates remain valid and fall
|
||||
back to Python inference. Normalized formatter hints are routed into Krea,
|
||||
SDXL, and caption naturalization through `all` plus the matching formatter
|
||||
route only.
|
||||
|
||||
Improve later:
|
||||
|
||||
- introduce optional `family` and `action_type` fields on item templates so
|
||||
Python filters do less keyword guessing;
|
||||
- add `formatter_hint` fields only where needed, not globally;
|
||||
- add a JSON audit that checks every referenced expression/composition/scene pool
|
||||
exists.
|
||||
- keep `tools/prompt_map_audit.py` passing; it now checks referenced
|
||||
expression/composition/scene pools, item-template axes, object-template
|
||||
metadata values for both string and object templates, category/subcategory
|
||||
identity uniqueness, registered formatter policy coverage for route
|
||||
families, and critical route documentation plus expected smoke coverage.
|
||||
|
||||
### Node / UI Path
|
||||
|
||||
Owner: `__init__.py`, `loop_nodes.py`, `web/*.js`.
|
||||
Owner: `__init__.py`, `node_builder.py`, `node_seed_resolution.py`,
|
||||
`node_camera.py`, `node_character.py`, `node_hardcore_position.py`,
|
||||
`node_formatter.py`, `node_insta.py`, `node_route_config.py`,
|
||||
`node_profile_filter.py`, `loop_nodes.py`, `web/*.js`.
|
||||
|
||||
Keep here:
|
||||
|
||||
@@ -194,13 +578,68 @@ Keep here:
|
||||
- widget behavior;
|
||||
- button actions;
|
||||
- dynamic input slots.
|
||||
- direct and config-driven builder node declarations in `node_builder.py`.
|
||||
- seed and resolution utility node declarations in `node_seed_resolution.py`.
|
||||
- camera utility node declarations in `node_camera.py`.
|
||||
- character pool, slot, and profile node declarations in `node_character.py`.
|
||||
- hardcore position pool/filter node declarations in
|
||||
`node_hardcore_position.py`.
|
||||
- caption/Krea2/SDXL formatter node declarations in `node_formatter.py`.
|
||||
- Insta/OF options and prompt-pair node declarations in `node_insta.py`.
|
||||
- route/category/location/composition/cast config node declarations in
|
||||
`node_route_config.py`.
|
||||
- profile/filter/ethnicity-list node declarations in `node_profile_filter.py`.
|
||||
|
||||
Already isolated:
|
||||
|
||||
- direct and config-driven prompt builder nodes live in `node_builder.py`, with
|
||||
registration maps imported by `__init__.py`.
|
||||
- seed axis salts/aliases, seed mode choices, lock builders, seed config
|
||||
parsing, row seed math, and deterministic axis RNG live in `seed_config.py`;
|
||||
seed/global-seed/seed-locker nodes live in `node_seed_resolution.py`, with
|
||||
registration maps imported by `__init__.py`.
|
||||
- SDXL/Krea2 resolution utility nodes live in `node_seed_resolution.py`, with
|
||||
registration maps imported by `__init__.py`.
|
||||
- camera/orbit/Qwen translator utility nodes live in `node_camera.py`, using
|
||||
`camera_config.py` for option lists and JSON builders, with registration maps
|
||||
imported by `__init__.py`.
|
||||
- hair, age/body/eyes/clothing pools, manual character details, character
|
||||
slots, and profile save/load nodes live in `node_character.py`, with
|
||||
registration maps imported by `__init__.py`.
|
||||
- hardcore position pool and action filter nodes live in
|
||||
`node_hardcore_position.py`, with registration maps imported by
|
||||
`__init__.py`.
|
||||
- caption naturalizer, Krea2 formatter, and SDXL formatter nodes live in
|
||||
`node_formatter.py`, with registration maps imported by `__init__.py`.
|
||||
- Insta/OF options and dual prompt-pair nodes live in `node_insta.py`, with
|
||||
registration maps imported by `__init__.py`.
|
||||
- category preset, location/composition pool, location theme, and cast config
|
||||
utility nodes live in `node_route_config.py`, with registration maps imported
|
||||
by `__init__.py`.
|
||||
- generation profile, advanced filter, and ethnicity list utility nodes live in
|
||||
`node_profile_filter.py`, with registration maps imported by `__init__.py`.
|
||||
- index-switch constants, index-base normalization, missing-input behavior,
|
||||
route-output selection, status text, and lazy-input selection live in
|
||||
`index_switch_policy.py`; `loop_nodes.py` keeps the ComfyUI node wrapper and
|
||||
accumulator/loop runtime logic.
|
||||
- node input tooltip inventory, node-specific tooltip overrides, dynamic input
|
||||
fallback tooltip rules, and tooltip injection live in `node_tooltips.py`;
|
||||
`__init__.py` only applies the installer to the assembled node registry.
|
||||
- node registration drift is checked by `tools/prompt_map_audit.py`: concrete
|
||||
`SxCP...` node classes in node modules must be present in their module class
|
||||
mappings and matching display-name mappings before they can silently
|
||||
disappear from ComfyUI.
|
||||
- profile-save and accumulator server payload handling lives in
|
||||
`server_routes.py`; `__init__.py` only wires those pure handlers to ComfyUI
|
||||
JSON responses, and `tools/prompt_smoke.py` covers the handlers without
|
||||
importing ComfyUI.
|
||||
|
||||
Improve later:
|
||||
|
||||
- split large node classes into files by family;
|
||||
- split remaining large node classes into files by family;
|
||||
- keep node display names, return names, and docs in sync through the audit
|
||||
helper;
|
||||
- add small endpoint tests for profile/accumulator/index-switch routes.
|
||||
- add more endpoint tests when new server routes are introduced.
|
||||
|
||||
## Path-Specific Improvements
|
||||
|
||||
@@ -209,30 +648,38 @@ Improve later:
|
||||
Near-term:
|
||||
|
||||
- Add final row hygiene already done through `prompt_hygiene.py`.
|
||||
- Add a metadata smoke checker for representative rows through
|
||||
`tools/prompt_smoke.py`.
|
||||
- Add a metadata smoke checker for representative generated rows and static
|
||||
formatter fixtures through `tools/prompt_smoke.py`.
|
||||
- Normalize every row with one function before JSON serialization.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Extract category loading and role graph logic.
|
||||
- Convert keyword-heavy interaction filtering to template metadata.
|
||||
- Keep category loading, prompt-row routing, and role-graph routing in their
|
||||
extracted owner modules instead of rebuilding them inside
|
||||
`prompt_builder.py`.
|
||||
- Add new template metadata only when a generated route needs a concrete action,
|
||||
position, or formatter hint that is not already expressible.
|
||||
|
||||
### Insta/OF Pair
|
||||
|
||||
Near-term:
|
||||
|
||||
- Normalize pair metadata with one helper.
|
||||
- Normalize pair metadata with one helper, including embedded row prompt,
|
||||
caption, negative, and side-specific metadata synchronization.
|
||||
- Confirm pair prompts, captions, and soft/hard rows carry the same sanitized
|
||||
scene/camera/clothing fields.
|
||||
- Keep same-room pair continuity synchronized in both assembled prompt text and
|
||||
`hardcore_row.scene_text`; `tools/prompt_smoke.py` covers this drift case.
|
||||
- Keep pair seed behavior synchronized across soft/hard rows; the route
|
||||
simulator now checks locked pair determinism and person, scene, content,
|
||||
pose, expression, and composition rerolls.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Make pair camera and clothing phases explicit subfunctions.
|
||||
- Add smoke fixtures for same-cast, POV man, explicit nude, and different-camera
|
||||
modes.
|
||||
- Keep camera and clothing phases in `pair_camera.py` and `pair_clothing.py`;
|
||||
extend those modules when a pair output shows concrete camera/clothing drift.
|
||||
- Add pair smoke fixtures only when a new pair option or observed output changes
|
||||
soft/hard cast, POV, explicit-nude, or split-camera metadata behavior.
|
||||
|
||||
### Krea2
|
||||
|
||||
@@ -241,13 +688,32 @@ Near-term:
|
||||
- Add final prose hygiene already done through `prompt_hygiene.py`.
|
||||
- Add smoke coverage through `tools/prompt_smoke.py` for metadata-driven Krea2
|
||||
formatting across built-in rows, hardcore rows, same-cast pairs, and POV
|
||||
pairs. Expand it next for close foreplay, POV penetration, and camera-scene
|
||||
preservation.
|
||||
pairs.
|
||||
- Cover camera-scene preservation through `tools/prompt_smoke.py` for single
|
||||
rows, split soft/hard pair cameras, and POV camera-scene routing.
|
||||
- Cover config-node routing through `tools/prompt_smoke.py` for category, cast,
|
||||
generation profile, seed lock, camera, location theme, and composition config.
|
||||
- Cover close foreplay and POV penetration Krea routes so raw labels, invalid
|
||||
surface grammar, normal third-person camera text, and composition punctuation
|
||||
drift are caught.
|
||||
- Cover POV outercourse, oral, penetration, anal, and front/back double-contact
|
||||
Krea routes so selected position geometry stays synchronized with metadata.
|
||||
- Cover generated climax routes through Krea, SDXL, and natural caption outputs
|
||||
so source aftermath placement and formatter details cannot drift apart.
|
||||
- Cover generated interaction routes through Krea, SDXL, and natural caption
|
||||
outputs so source contact/guidance/presentation wording stays metadata-driven.
|
||||
- Cover generated fallback role routes through Krea, SDXL, and natural caption
|
||||
outputs so solo and same-sex paths do not remain untested edge behavior.
|
||||
- Keep route simulation coverage updated when adding action or position
|
||||
families, so generated Krea2, SDXL, and natural-caption paths prove the new
|
||||
family reaches formatter metadata routes.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Dispatch action rewriting by action family.
|
||||
- Split Krea semantic helpers into smaller modules.
|
||||
- Keep action-family rewriting dispatched through `krea_action_dispatch.py` and
|
||||
the existing `krea_action_*` / `krea_pov*` helpers.
|
||||
- Add or split Krea helpers only for an observed route failure or a new
|
||||
action-family metadata path.
|
||||
|
||||
### SDXL
|
||||
|
||||
@@ -281,10 +747,13 @@ Near-term:
|
||||
- Keep scene-camera adapters scoped by location family.
|
||||
- Use the memory note in
|
||||
`/home/ethanfel/.codex/memories/scene-camera-system.md` when editing POV.
|
||||
- Keep `scene_camera_adapters.py` as the owner for location-aware camera prose;
|
||||
add new location families there one at a time.
|
||||
- Keep `row_camera.py` as the owner for inserting camera/scene directives into
|
||||
generated rows, including POV suppression of normal third-person camera text.
|
||||
|
||||
Medium-term:
|
||||
|
||||
- Move coworking adapter into a scene-camera adapter module.
|
||||
- Build new adapters one location family at a time.
|
||||
|
||||
## Invariants To Preserve
|
||||
@@ -300,10 +769,10 @@ Medium-term:
|
||||
|
||||
## Recommended Next Passes
|
||||
|
||||
1. Expand `tools/prompt_smoke.py` with camera-scene, explicit nude, and
|
||||
different-camera pair fixtures.
|
||||
2. Split Krea action/POV/clothing helpers into separate modules.
|
||||
3. Add category JSON pool reference validation to `tools/prompt_map_audit.py`.
|
||||
4. Extract scene-camera adapters from `prompt_builder.py`.
|
||||
5. Split `__init__.py` node classes by family after behavior is covered by smoke
|
||||
checks.
|
||||
1. Keep new node classes in their owning `node_*.py` or `loop_nodes.py`
|
||||
module, with registration/display docs covered by the audit.
|
||||
2. Keep `hardcore_role_graphs.py` as the dispatch surface; add behavior in the
|
||||
existing `hardcore_role_*` action-family modules only when a concrete
|
||||
generated edge case needs it.
|
||||
3. Add route-level smoke fixtures only for observed generated edge cases or new
|
||||
metadata fields that affect Krea2, SDXL, or caption output.
|
||||
|
||||
+547
-148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
ETHNICITY_FILTER_CHOICES = [
|
||||
"any",
|
||||
"european",
|
||||
"mediterranean_mena",
|
||||
"latina",
|
||||
"east_asian",
|
||||
"southeast_asian",
|
||||
"south_asian",
|
||||
"black_african",
|
||||
"indigenous",
|
||||
"mixed",
|
||||
"asian",
|
||||
"white_asian",
|
||||
"western_european",
|
||||
"french_european",
|
||||
"germanic_european",
|
||||
"nordic_european",
|
||||
"celtic_european",
|
||||
"slavic_european",
|
||||
"baltic_european",
|
||||
"alpine_european",
|
||||
"balkan_european",
|
||||
"greek_mediterranean",
|
||||
"italian_mediterranean",
|
||||
"iberian_mediterranean",
|
||||
]
|
||||
ETHNICITY_LIST_KEYS = tuple(choice for choice in ETHNICITY_FILTER_CHOICES if choice != "any")
|
||||
ETHNICITY_BASE_LIST_KEYS = (
|
||||
"european",
|
||||
"mediterranean_mena",
|
||||
"latina",
|
||||
"east_asian",
|
||||
"southeast_asian",
|
||||
"south_asian",
|
||||
"black_african",
|
||||
"indigenous",
|
||||
"mixed",
|
||||
)
|
||||
EUROPEAN_REGIONAL_LIST_KEYS = (
|
||||
"western_european",
|
||||
"french_european",
|
||||
"germanic_european",
|
||||
"nordic_european",
|
||||
"celtic_european",
|
||||
"slavic_european",
|
||||
"baltic_european",
|
||||
"alpine_european",
|
||||
"balkan_european",
|
||||
)
|
||||
MEDITERRANEAN_REGIONAL_LIST_KEYS = (
|
||||
"greek_mediterranean",
|
||||
"italian_mediterranean",
|
||||
"iberian_mediterranean",
|
||||
)
|
||||
ETHNICITY_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "default"}
|
||||
|
||||
|
||||
def ethnicity_text_from_value(value: Any) -> str:
|
||||
if isinstance(value, dict):
|
||||
return str(value.get("ethnicity") or "").strip()
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return text
|
||||
if isinstance(raw, dict):
|
||||
return str(raw.get("ethnicity") or "").strip()
|
||||
return text
|
||||
|
||||
|
||||
def is_valid_ethnicity_filter(value: Any) -> bool:
|
||||
text = ethnicity_text_from_value(value)
|
||||
return text == "any" or text in ETHNICITY_FILTER_CHOICES or "+" in text
|
||||
|
||||
|
||||
def normalize_ethnicity_filter(value: Any, default: str = "any", allow_random: bool = False) -> str:
|
||||
text = ethnicity_text_from_value(value)
|
||||
if text.lower() in ETHNICITY_RANDOM_TOKENS:
|
||||
return "random" if allow_random else default
|
||||
return text if is_valid_ethnicity_filter(text) else default
|
||||
|
||||
|
||||
def build_filter_config_json(
|
||||
ethnicity: str = "any",
|
||||
figure: str = "curvy",
|
||||
no_plus_women: bool = False,
|
||||
no_black: bool = False,
|
||||
include_european: bool = True,
|
||||
include_mediterranean_mena: bool = True,
|
||||
include_latina: bool = True,
|
||||
include_east_asian: bool = True,
|
||||
include_southeast_asian: bool = True,
|
||||
include_south_asian: bool = True,
|
||||
include_black_african: bool = True,
|
||||
include_indigenous: bool = True,
|
||||
include_mixed: bool = True,
|
||||
include_plus_size: bool = True,
|
||||
) -> str:
|
||||
include_flags = {
|
||||
"european": include_european,
|
||||
"mediterranean_mena": include_mediterranean_mena,
|
||||
"latina": include_latina,
|
||||
"east_asian": include_east_asian,
|
||||
"southeast_asian": include_southeast_asian,
|
||||
"south_asian": include_south_asian,
|
||||
"black_african": include_black_african,
|
||||
"indigenous": include_indigenous,
|
||||
"mixed": include_mixed,
|
||||
}
|
||||
selected_ethnicities = [key for key, enabled in include_flags.items() if enabled]
|
||||
disabled_ethnicities = [key for key, enabled in include_flags.items() if not enabled]
|
||||
enabled_ethnicities = list(selected_ethnicities)
|
||||
if enabled_ethnicities:
|
||||
enabled_ethnicities.extend(f"exclude_{key}" for key in disabled_ethnicities)
|
||||
if 0 < len(selected_ethnicities) < len(include_flags):
|
||||
ethnicity = "+".join(enabled_ethnicities)
|
||||
elif not is_valid_ethnicity_filter(ethnicity):
|
||||
ethnicity = "any"
|
||||
return json.dumps(
|
||||
{
|
||||
"ethnicity": ethnicity,
|
||||
"ethnicity_includes": selected_ethnicities,
|
||||
"figure": figure if figure in ("curvy", "balanced", "bombshell", "random") else "curvy",
|
||||
"include_plus_size": bool(include_plus_size),
|
||||
"include_black_african": bool(include_black_african),
|
||||
"no_plus_women": not bool(include_plus_size) or bool(no_plus_women),
|
||||
"no_black": not bool(include_black_african) or bool(no_black),
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def build_ethnicity_list_json(
|
||||
include_european: bool = False,
|
||||
include_mediterranean_mena: bool = False,
|
||||
include_latina: bool = False,
|
||||
include_east_asian: bool = False,
|
||||
include_southeast_asian: bool = False,
|
||||
include_south_asian: bool = False,
|
||||
include_black_african: bool = False,
|
||||
include_indigenous: bool = False,
|
||||
include_mixed: bool = False,
|
||||
include_asian: bool = False,
|
||||
include_white_asian: bool = False,
|
||||
include_western_european: bool = False,
|
||||
include_french_european: bool = False,
|
||||
include_germanic_european: bool = False,
|
||||
include_nordic_european: bool = False,
|
||||
include_celtic_european: bool = False,
|
||||
include_slavic_european: bool = False,
|
||||
include_baltic_european: bool = False,
|
||||
include_alpine_european: bool = False,
|
||||
include_balkan_european: bool = False,
|
||||
include_greek_mediterranean: bool = False,
|
||||
include_italian_mediterranean: bool = False,
|
||||
include_iberian_mediterranean: bool = False,
|
||||
strict_excludes: bool = True,
|
||||
) -> dict[str, str]:
|
||||
include_flags = {
|
||||
"european": include_european,
|
||||
"mediterranean_mena": include_mediterranean_mena,
|
||||
"latina": include_latina,
|
||||
"east_asian": include_east_asian,
|
||||
"southeast_asian": include_southeast_asian,
|
||||
"south_asian": include_south_asian,
|
||||
"black_african": include_black_african,
|
||||
"indigenous": include_indigenous,
|
||||
"mixed": include_mixed,
|
||||
"asian": include_asian,
|
||||
"white_asian": include_white_asian,
|
||||
"western_european": include_western_european,
|
||||
"french_european": include_french_european,
|
||||
"germanic_european": include_germanic_european,
|
||||
"nordic_european": include_nordic_european,
|
||||
"celtic_european": include_celtic_european,
|
||||
"slavic_european": include_slavic_european,
|
||||
"baltic_european": include_baltic_european,
|
||||
"alpine_european": include_alpine_european,
|
||||
"balkan_european": include_balkan_european,
|
||||
"greek_mediterranean": include_greek_mediterranean,
|
||||
"italian_mediterranean": include_italian_mediterranean,
|
||||
"iberian_mediterranean": include_iberian_mediterranean,
|
||||
}
|
||||
selected = [key for key in ETHNICITY_LIST_KEYS if include_flags.get(key)]
|
||||
if not selected or set(selected) == set(ETHNICITY_LIST_KEYS):
|
||||
ethnicity = "any"
|
||||
else:
|
||||
tokens = list(selected)
|
||||
if strict_excludes:
|
||||
protected: set[str] = set()
|
||||
if "asian" in selected:
|
||||
protected.update(("east_asian", "southeast_asian", "south_asian"))
|
||||
if "white_asian" in selected:
|
||||
protected.update(("european", "east_asian", "southeast_asian", "south_asian", "mixed"))
|
||||
if any(key in selected for key in EUROPEAN_REGIONAL_LIST_KEYS):
|
||||
protected.add("european")
|
||||
if any(key in selected for key in MEDITERRANEAN_REGIONAL_LIST_KEYS):
|
||||
protected.add("mediterranean_mena")
|
||||
if "mixed" in selected:
|
||||
protected.update(ETHNICITY_BASE_LIST_KEYS)
|
||||
tokens.extend(
|
||||
f"exclude_{key}"
|
||||
for key in ETHNICITY_BASE_LIST_KEYS
|
||||
if key not in selected and key not in protected
|
||||
)
|
||||
ethnicity = "+".join(tokens)
|
||||
filter_config = {
|
||||
"ethnicity": ethnicity,
|
||||
"ethnicity_includes": selected,
|
||||
}
|
||||
summary = "any ethnicity" if ethnicity == "any" else "ethnicity list: " + ", ".join(selected)
|
||||
return {
|
||||
"ethnicity": ethnicity,
|
||||
"filter_config": json.dumps(filter_config, ensure_ascii=True, sort_keys=True),
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def parse_filter_config(filter_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
defaults = {
|
||||
"ethnicity": "any",
|
||||
"figure": "curvy",
|
||||
"no_plus_women": False,
|
||||
"no_black": False,
|
||||
"include_plus_size": True,
|
||||
"include_black_african": True,
|
||||
}
|
||||
if not filter_config:
|
||||
return defaults
|
||||
if isinstance(filter_config, dict):
|
||||
raw = filter_config
|
||||
else:
|
||||
text = str(filter_config).strip()
|
||||
if not text.startswith("{"):
|
||||
raw = {"ethnicity": text}
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid filter_config JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("filter_config must be a JSON object")
|
||||
parsed = {**defaults, **raw}
|
||||
parsed["ethnicity"] = normalize_ethnicity_filter(parsed.get("ethnicity"), "any")
|
||||
parsed["figure"] = parsed["figure"] if parsed.get("figure") in ("curvy", "balanced", "bombshell", "random") else "curvy"
|
||||
parsed["include_plus_size"] = bool(parsed.get("include_plus_size"))
|
||||
parsed["include_black_african"] = bool(parsed.get("include_black_african"))
|
||||
parsed["no_plus_women"] = bool(parsed.get("no_plus_women"))
|
||||
parsed["no_black"] = bool(parsed.get("no_black"))
|
||||
return parsed
|
||||
|
||||
|
||||
_ethnicity_text_from_value = ethnicity_text_from_value
|
||||
_is_valid_ethnicity_filter = is_valid_ethnicity_filter
|
||||
_parse_filter_config = parse_filter_config
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
DETAIL_LEVELS = ("balanced", "concise", "dense")
|
||||
DEFAULT_DETAIL_LEVEL = "balanced"
|
||||
|
||||
|
||||
def detail_level_choices() -> list[str]:
|
||||
return list(DETAIL_LEVELS)
|
||||
|
||||
|
||||
def normalize_detail_level(value: Any) -> str:
|
||||
level = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
return level if level in DETAIL_LEVELS else DEFAULT_DETAIL_LEVEL
|
||||
|
||||
|
||||
def detail_allows(level: Any, dense_only: bool = False) -> bool:
|
||||
level = normalize_detail_level(level)
|
||||
if dense_only:
|
||||
return level == "dense"
|
||||
return level != "concise"
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import row_normalization as row_normalization_policy
|
||||
except ImportError: # Allows local smoke tests with `python tools/prompt_smoke.py`.
|
||||
import row_normalization as row_normalization_policy
|
||||
|
||||
|
||||
DEFAULT_PROMPT_FIELD_LABELS = (
|
||||
"Ages",
|
||||
"Body types",
|
||||
"Cast",
|
||||
"Cast descriptors",
|
||||
"Characters",
|
||||
"Softcore setup",
|
||||
"Hardcore setup",
|
||||
"POV participant",
|
||||
"Body exposure",
|
||||
"Scene",
|
||||
"Setting",
|
||||
"Pose",
|
||||
"Sexual pose",
|
||||
"Sexual scene",
|
||||
"Facial expression",
|
||||
"Facial expressions",
|
||||
"Clothing",
|
||||
"Clothing state",
|
||||
"Visual clothing state",
|
||||
"Outfit",
|
||||
"Erotic outfit",
|
||||
"Teaser outfit detail",
|
||||
"Softcore visual reference",
|
||||
"Visible remaining styling",
|
||||
"Prop/detail",
|
||||
"Composition",
|
||||
"Role graph",
|
||||
"Camera",
|
||||
"Camera control",
|
||||
"Use",
|
||||
"Avoid",
|
||||
)
|
||||
|
||||
INPUT_HINT_AUTO = "auto"
|
||||
INPUT_HINT_METADATA = "metadata_json"
|
||||
INPUT_HINT_PROMPT = "prompt"
|
||||
INPUT_HINT_CAPTION_OR_PROMPT = "caption_or_prompt"
|
||||
TEXT_INPUT_HINTS = (INPUT_HINT_PROMPT, INPUT_HINT_CAPTION_OR_PROMPT)
|
||||
FORMATTER_INPUT_HINTS = (INPUT_HINT_AUTO, INPUT_HINT_METADATA, INPUT_HINT_PROMPT, INPUT_HINT_CAPTION_OR_PROMPT)
|
||||
METADATA_INPUT_HINTS = (INPUT_HINT_AUTO, INPUT_HINT_METADATA)
|
||||
|
||||
_INPUT_HINT_ALIASES = {
|
||||
"caption": INPUT_HINT_CAPTION_OR_PROMPT,
|
||||
"caption_prompt": INPUT_HINT_CAPTION_OR_PROMPT,
|
||||
"caption_or_text": INPUT_HINT_CAPTION_OR_PROMPT,
|
||||
"metadata": INPUT_HINT_METADATA,
|
||||
"metadata json": INPUT_HINT_METADATA,
|
||||
"source_json": INPUT_HINT_AUTO,
|
||||
"source text": INPUT_HINT_PROMPT,
|
||||
"source_text": INPUT_HINT_PROMPT,
|
||||
"text": INPUT_HINT_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
def prompt_field_labels() -> tuple[str, ...]:
|
||||
return DEFAULT_PROMPT_FIELD_LABELS
|
||||
|
||||
|
||||
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 maybe_json(text: Any) -> dict[str, Any] | None:
|
||||
text = clean_text(text)
|
||||
if not text.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def normalize_input_metadata(row: dict[str, Any]) -> dict[str, Any]:
|
||||
row = dict(row)
|
||||
trigger = str(row.get("trigger") or "").strip()
|
||||
if is_pair_metadata(row):
|
||||
return row_normalization_policy.normalize_pair_metadata(row, active_trigger=trigger)
|
||||
return row_normalization_policy.sanitize_metadata_row_text(row, active_trigger=trigger)
|
||||
|
||||
|
||||
def is_pair_metadata(row: Any) -> bool:
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
soft_side = (
|
||||
isinstance(row.get("softcore_row"), dict)
|
||||
or bool(clean_text(row.get("softcore_prompt")))
|
||||
or bool(clean_text(row.get("softcore_caption")))
|
||||
)
|
||||
hard_side = (
|
||||
isinstance(row.get("hardcore_row"), dict)
|
||||
or bool(clean_text(row.get("hardcore_prompt")))
|
||||
or bool(clean_text(row.get("hardcore_caption")))
|
||||
)
|
||||
return soft_side and hard_side
|
||||
|
||||
|
||||
def normalize_input_hint(value: Any, *, text_hint: str = INPUT_HINT_PROMPT) -> str:
|
||||
hint = clean_text(value).lower().replace("-", "_")
|
||||
hint = _INPUT_HINT_ALIASES.get(hint, hint)
|
||||
if hint in (INPUT_HINT_AUTO, INPUT_HINT_METADATA):
|
||||
return hint
|
||||
if hint in TEXT_INPUT_HINTS:
|
||||
return text_hint if text_hint in TEXT_INPUT_HINTS else hint
|
||||
return INPUT_HINT_AUTO
|
||||
|
||||
|
||||
def input_hint_choices(*, text_hint: str = INPUT_HINT_PROMPT) -> list[str]:
|
||||
text_hint = text_hint if text_hint in TEXT_INPUT_HINTS else INPUT_HINT_PROMPT
|
||||
return [INPUT_HINT_AUTO, INPUT_HINT_METADATA, text_hint]
|
||||
|
||||
|
||||
def row_from_inputs(
|
||||
source_text: str,
|
||||
metadata_json: str,
|
||||
input_hint: str,
|
||||
*,
|
||||
metadata_methods: tuple[str, ...] = METADATA_INPUT_HINTS,
|
||||
text_hint: str = INPUT_HINT_PROMPT,
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
input_hint = normalize_input_hint(input_hint, text_hint=text_hint)
|
||||
if input_hint in metadata_methods:
|
||||
for text, method in ((metadata_json, "metadata_json"), (source_text, "source_json")):
|
||||
row = maybe_json(text)
|
||||
if row is not None:
|
||||
return normalize_input_metadata(row), method
|
||||
return None, "text"
|
||||
|
||||
|
||||
def strip_trigger_prefix(
|
||||
text: Any,
|
||||
trigger_candidates: tuple[str, ...] | list[str],
|
||||
*,
|
||||
preserve_trigger: bool = False,
|
||||
remove_exact: bool = False,
|
||||
) -> str:
|
||||
text = clean_text(text)
|
||||
if remove_exact:
|
||||
text = text.strip(" ,")
|
||||
if preserve_trigger:
|
||||
return text
|
||||
for trigger in trigger_candidates:
|
||||
trigger = clean_text(trigger)
|
||||
if not trigger:
|
||||
continue
|
||||
if text.lower().startswith(trigger.lower() + ","):
|
||||
return text[len(trigger) + 1 :].strip(" ,")
|
||||
if text.lower().startswith(trigger.lower() + "."):
|
||||
return text[len(trigger) + 1 :].strip(" ,")
|
||||
if remove_exact and text.lower() == trigger.lower():
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def split_avoid(text: Any) -> tuple[str, str]:
|
||||
text = clean_text(text)
|
||||
match = re.search(r"\bAvoid:\s*(.*)$", text)
|
||||
if not match:
|
||||
return text, ""
|
||||
return text[: match.start()].strip(" ."), match.group(1).strip(" .")
|
||||
|
||||
|
||||
def strip_prompt_field_labels(
|
||||
text: Any,
|
||||
*,
|
||||
field_labels: tuple[str, ...] | list[str] = DEFAULT_PROMPT_FIELD_LABELS,
|
||||
) -> str:
|
||||
text = clean_text(text)
|
||||
if not text:
|
||||
return ""
|
||||
labels = "|".join(re.escape(name) for name in sorted(field_labels, key=len, reverse=True))
|
||||
return clean_text(re.sub(rf"\b(?:{labels}):\s*", "", text))
|
||||
|
||||
|
||||
def prompt_field(
|
||||
text: Any,
|
||||
label: str,
|
||||
*,
|
||||
field_labels: tuple[str, ...] | list[str] = DEFAULT_PROMPT_FIELD_LABELS,
|
||||
) -> str:
|
||||
text = clean_text(text)
|
||||
if not text:
|
||||
return ""
|
||||
labels = "|".join(re.escape(name) for name in 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, ...] = (),
|
||||
*,
|
||||
field_labels: tuple[str, ...] | list[str] = DEFAULT_PROMPT_FIELD_LABELS,
|
||||
) -> 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, field_labels=field_labels)
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import route_metadata as route_metadata_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import route_metadata as route_metadata_policy
|
||||
|
||||
|
||||
PAIR_SIDES = ("softcore", "hardcore")
|
||||
|
||||
|
||||
def route_trace_json(**values: Any) -> str:
|
||||
trace: dict[str, Any] = {}
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
trace[key] = value
|
||||
return json.dumps(trace, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def _pair_selected_side(target: Any, selected_side: Any = "") -> str:
|
||||
side = str(selected_side or "").strip().lower()
|
||||
if side in PAIR_SIDES:
|
||||
return side
|
||||
target_side = str(target or "").strip().lower()
|
||||
return target_side if target_side in PAIR_SIDES else "softcore"
|
||||
|
||||
|
||||
def _add_if_value(trace: dict[str, Any], key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return
|
||||
if isinstance(value, (list, tuple, set)) and not value:
|
||||
return
|
||||
trace[key] = value
|
||||
|
||||
|
||||
def metadata_trace_fields(row: Any, *, target: Any = "", selected_side: Any = "") -> dict[str, Any]:
|
||||
"""Return compact row metadata fields for formatter route traces.
|
||||
|
||||
The trace intentionally carries routing/debug identifiers, not full prompt
|
||||
prose or cast descriptors.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
trace: dict[str, Any] = {}
|
||||
source_row = row
|
||||
if isinstance(row.get("softcore_row"), dict) or isinstance(row.get("hardcore_row"), dict):
|
||||
side = _pair_selected_side(target, selected_side)
|
||||
source_row = row.get(f"{side}_row") if isinstance(row.get(f"{side}_row"), dict) else {}
|
||||
trace["metadata_kind"] = "pair"
|
||||
trace["selected_side"] = side
|
||||
else:
|
||||
trace["metadata_kind"] = "row"
|
||||
|
||||
if not isinstance(source_row, dict):
|
||||
return trace
|
||||
|
||||
_add_if_value(trace, "metadata_category", source_row.get("main_category") or source_row.get("category"))
|
||||
_add_if_value(trace, "metadata_subcategory", source_row.get("subcategory"))
|
||||
_add_if_value(trace, "action_family", route_metadata_policy.row_action_family(source_row))
|
||||
_add_if_value(trace, "position_family", route_metadata_policy.row_position_family(source_row))
|
||||
_add_if_value(trace, "position_key", source_row.get("position_key"))
|
||||
_add_if_value(trace, "position_keys", route_metadata_policy.row_position_keys(source_row, include_unknown=True))
|
||||
_add_if_value(trace, "scene_profile", source_row.get("scene_camera_profile_key"))
|
||||
_add_if_value(trace, "pov_labels", source_row.get("pov_character_labels"))
|
||||
return trace
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
FORMATTER_TARGETS = ("auto", "single", "softcore", "hardcore")
|
||||
PAIR_SIDE_TARGETS = ("softcore", "hardcore")
|
||||
DEFAULT_FORMATTER_TARGET = "auto"
|
||||
DEFAULT_PAIR_SELECTED_SIDE = "softcore"
|
||||
|
||||
_TARGET_ALIASES = {
|
||||
"soft": "softcore",
|
||||
"soft_core": "softcore",
|
||||
"hard": "hardcore",
|
||||
"hard_core": "hardcore",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairTargetPolicy:
|
||||
target: str
|
||||
pair_target: str
|
||||
selected_side: str
|
||||
include_softcore: bool
|
||||
include_hardcore: bool
|
||||
|
||||
|
||||
def target_choices() -> list[str]:
|
||||
return list(FORMATTER_TARGETS)
|
||||
|
||||
|
||||
def normalize_target(value: Any) -> str:
|
||||
target = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
target = _TARGET_ALIASES.get(target, target)
|
||||
return target if target in FORMATTER_TARGETS else DEFAULT_FORMATTER_TARGET
|
||||
|
||||
|
||||
def pair_target(value: Any) -> str:
|
||||
target = normalize_target(value)
|
||||
return target if target in PAIR_SIDE_TARGETS else DEFAULT_FORMATTER_TARGET
|
||||
|
||||
|
||||
def pair_selected_side(value: Any, default: str = DEFAULT_PAIR_SELECTED_SIDE) -> str:
|
||||
side = pair_target(value)
|
||||
if side in PAIR_SIDE_TARGETS:
|
||||
return side
|
||||
return default if default in PAIR_SIDE_TARGETS else DEFAULT_PAIR_SELECTED_SIDE
|
||||
|
||||
|
||||
def pair_policy(value: Any, *, selected_default: str = DEFAULT_PAIR_SELECTED_SIDE) -> PairTargetPolicy:
|
||||
target = normalize_target(value)
|
||||
side_target = pair_target(target)
|
||||
selected_side = pair_selected_side(side_target, selected_default)
|
||||
return PairTargetPolicy(
|
||||
target=target,
|
||||
pair_target=side_target,
|
||||
selected_side=selected_side,
|
||||
include_softcore=side_target in ("auto", "softcore"),
|
||||
include_hardcore=side_target in ("auto", "hardcore"),
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
GENERATION_PROFILE_PRESETS = {
|
||||
"balanced": {
|
||||
"clothing": "full",
|
||||
"poses": "standard",
|
||||
"expression_enabled": True,
|
||||
"expression_intensity": 0.5,
|
||||
"backside_bias": 0.0,
|
||||
"minimal_clothing_ratio": -1.0,
|
||||
"standard_pose_ratio": -1.0,
|
||||
"trigger": "sxcpinup_coloredpencil",
|
||||
"prepend_trigger_to_prompt": True,
|
||||
},
|
||||
"casual_clean": {
|
||||
"clothing": "full",
|
||||
"poses": "standard",
|
||||
"expression_enabled": True,
|
||||
"expression_intensity": 0.35,
|
||||
"backside_bias": 0.0,
|
||||
"minimal_clothing_ratio": -1.0,
|
||||
"standard_pose_ratio": -1.0,
|
||||
"trigger": "sxcpinup_coloredpencil",
|
||||
"prepend_trigger_to_prompt": True,
|
||||
},
|
||||
"evocative_softcore": {
|
||||
"clothing": "minimal",
|
||||
"poses": "evocative",
|
||||
"expression_enabled": True,
|
||||
"expression_intensity": 0.65,
|
||||
"backside_bias": 0.2,
|
||||
"minimal_clothing_ratio": -1.0,
|
||||
"standard_pose_ratio": -1.0,
|
||||
"trigger": "sxcpinup_coloredpencil",
|
||||
"prepend_trigger_to_prompt": True,
|
||||
},
|
||||
"hardcore_intense": {
|
||||
"clothing": "minimal",
|
||||
"poses": "evocative",
|
||||
"expression_enabled": True,
|
||||
"expression_intensity": 0.9,
|
||||
"backside_bias": 0.0,
|
||||
"minimal_clothing_ratio": -1.0,
|
||||
"standard_pose_ratio": -1.0,
|
||||
"trigger": "sxcpinup_coloredpencil",
|
||||
"prepend_trigger_to_prompt": True,
|
||||
},
|
||||
"krea2_friendly": {
|
||||
"clothing": "full",
|
||||
"poses": "standard",
|
||||
"expression_enabled": True,
|
||||
"expression_intensity": 0.55,
|
||||
"backside_bias": 0.0,
|
||||
"minimal_clothing_ratio": -1.0,
|
||||
"standard_pose_ratio": -1.0,
|
||||
"trigger": "sxcpinup_coloredpencil",
|
||||
"prepend_trigger_to_prompt": False,
|
||||
},
|
||||
"flux_original": {
|
||||
"clothing": "full",
|
||||
"poses": "standard",
|
||||
"expression_enabled": True,
|
||||
"expression_intensity": 0.5,
|
||||
"backside_bias": 0.0,
|
||||
"minimal_clothing_ratio": -1.0,
|
||||
"standard_pose_ratio": -1.0,
|
||||
"trigger": "sxcpinup_coloredpencil",
|
||||
"prepend_trigger_to_prompt": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(min_value, min(max_value, number))
|
||||
|
||||
|
||||
def generation_profile_choices() -> list[str]:
|
||||
return list(GENERATION_PROFILE_PRESETS)
|
||||
|
||||
|
||||
def build_generation_profile_json(
|
||||
profile: str = "balanced",
|
||||
clothing_override: str = "profile_default",
|
||||
poses_override: str = "profile_default",
|
||||
expression_intensity_mode: str = "profile_default",
|
||||
expression_intensity: float = -1.0,
|
||||
backside_bias: float = -1.0,
|
||||
minimal_clothing_ratio: float = -1.0,
|
||||
standard_pose_ratio: float = -1.0,
|
||||
trigger_policy: str = "profile_default",
|
||||
expression_enabled: bool = True,
|
||||
) -> str:
|
||||
profile = profile if profile in GENERATION_PROFILE_PRESETS else "balanced"
|
||||
config = dict(GENERATION_PROFILE_PRESETS[profile])
|
||||
if clothing_override in ("full", "minimal", "random"):
|
||||
config["clothing"] = clothing_override
|
||||
if poses_override in ("standard", "evocative", "random"):
|
||||
config["poses"] = poses_override
|
||||
config["expression_enabled"] = not _is_false(expression_enabled)
|
||||
if expression_intensity_mode == "random":
|
||||
config["expression_intensity"] = -1.0
|
||||
elif expression_intensity_mode == "fixed" and float(expression_intensity) >= 0:
|
||||
config["expression_intensity"] = _clamped_float(expression_intensity, config["expression_intensity"])
|
||||
if float(backside_bias) >= 0:
|
||||
config["backside_bias"] = _clamped_float(backside_bias, config["backside_bias"])
|
||||
if float(minimal_clothing_ratio) >= 0:
|
||||
config["minimal_clothing_ratio"] = _clamped_float(minimal_clothing_ratio, config["minimal_clothing_ratio"])
|
||||
if float(standard_pose_ratio) >= 0:
|
||||
config["standard_pose_ratio"] = _clamped_float(standard_pose_ratio, config["standard_pose_ratio"])
|
||||
if trigger_policy == "prepend_trigger":
|
||||
config["prepend_trigger_to_prompt"] = True
|
||||
elif trigger_policy == "do_not_prepend":
|
||||
config["prepend_trigger_to_prompt"] = False
|
||||
config["profile"] = profile
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def parse_generation_profile(profile_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not profile_config:
|
||||
return dict(GENERATION_PROFILE_PRESETS["balanced"])
|
||||
if isinstance(profile_config, dict):
|
||||
raw = profile_config
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(profile_config))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid generation_profile JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("generation_profile must be a JSON object")
|
||||
profile = str(raw.get("profile") or "balanced")
|
||||
parsed = dict(GENERATION_PROFILE_PRESETS.get(profile, GENERATION_PROFILE_PRESETS["balanced"]))
|
||||
parsed.update(raw)
|
||||
parsed["clothing"] = parsed["clothing"] if parsed.get("clothing") in ("full", "minimal", "random") else "full"
|
||||
parsed["poses"] = parsed["poses"] if parsed.get("poses") in ("standard", "evocative", "random") else "standard"
|
||||
parsed["expression_enabled"] = not _is_false(parsed.get("expression_enabled", True))
|
||||
try:
|
||||
raw_expression_intensity = float(parsed.get("expression_intensity"))
|
||||
except (TypeError, ValueError):
|
||||
raw_expression_intensity = 0.5
|
||||
parsed["expression_intensity"] = -1.0 if raw_expression_intensity < 0 else _clamped_float(raw_expression_intensity, 0.5)
|
||||
parsed["backside_bias"] = _clamped_float(parsed.get("backside_bias"), 0.0)
|
||||
parsed["minimal_clothing_ratio"] = _clamped_float(parsed.get("minimal_clothing_ratio"), -1.0, -1.0, 1.0)
|
||||
parsed["standard_pose_ratio"] = _clamped_float(parsed.get("standard_pose_ratio"), -1.0, -1.0, 1.0)
|
||||
parsed["trigger"] = str(parsed.get("trigger") or "sxcpinup_coloredpencil")
|
||||
parsed["prepend_trigger_to_prompt"] = bool(parsed.get("prepend_trigger_to_prompt"))
|
||||
return parsed
|
||||
|
||||
|
||||
_parse_generation_profile = parse_generation_profile
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .krea_action_context import (
|
||||
axis_values_text,
|
||||
is_climax_text,
|
||||
is_foreplay_text,
|
||||
is_oral_text,
|
||||
is_outercourse_text,
|
||||
is_toy_assisted_double_text,
|
||||
is_vaginal_penetration_text,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from krea_action_context import (
|
||||
axis_values_text,
|
||||
is_climax_text,
|
||||
is_foreplay_text,
|
||||
is_oral_text,
|
||||
is_outercourse_text,
|
||||
is_toy_assisted_double_text,
|
||||
is_vaginal_penetration_text,
|
||||
)
|
||||
|
||||
|
||||
ACTION_CLIMAX = "climax"
|
||||
ACTION_ANAL = "anal"
|
||||
ACTION_FOREPLAY = "foreplay"
|
||||
ACTION_MANUAL = "manual"
|
||||
ACTION_OUTERCOURSE = "outercourse"
|
||||
ACTION_ORAL = "oral"
|
||||
ACTION_PENETRATION = "penetration"
|
||||
ACTION_THREESOME = "threesome"
|
||||
ACTION_GROUP = "group"
|
||||
ACTION_TOY_DOUBLE = "toy_double"
|
||||
ACTION_DEFAULT = "default"
|
||||
|
||||
HARDCORE_ACTION_FAMILY_CHOICES = {
|
||||
ACTION_CLIMAX,
|
||||
ACTION_ANAL,
|
||||
ACTION_FOREPLAY,
|
||||
ACTION_MANUAL,
|
||||
ACTION_OUTERCOURSE,
|
||||
ACTION_ORAL,
|
||||
ACTION_PENETRATION,
|
||||
ACTION_THREESOME,
|
||||
ACTION_GROUP,
|
||||
ACTION_TOY_DOUBLE,
|
||||
ACTION_DEFAULT,
|
||||
}
|
||||
|
||||
|
||||
def normalize_hardcore_action_family(value: Any, default: str = "") -> str:
|
||||
text = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
||||
aliases = {
|
||||
"penetrative": ACTION_PENETRATION,
|
||||
"penetrative_sex": ACTION_PENETRATION,
|
||||
"penetration_sex": ACTION_PENETRATION,
|
||||
"vaginal": ACTION_PENETRATION,
|
||||
"vaginal_penetration": ACTION_PENETRATION,
|
||||
"double": ACTION_TOY_DOUBLE,
|
||||
"double_penetration": ACTION_TOY_DOUBLE,
|
||||
"toy_double_penetration": ACTION_TOY_DOUBLE,
|
||||
"toy_assisted_double": ACTION_TOY_DOUBLE,
|
||||
"toy_assisted_double_penetration": ACTION_TOY_DOUBLE,
|
||||
"anal": ACTION_ANAL,
|
||||
"anal_sex": ACTION_ANAL,
|
||||
"anal_penetration": ACTION_ANAL,
|
||||
"outer_course": ACTION_OUTERCOURSE,
|
||||
"outercourse_sex": ACTION_OUTERCOURSE,
|
||||
"three_person": ACTION_THREESOME,
|
||||
"three_person_action": ACTION_THREESOME,
|
||||
"threesome": ACTION_THREESOME,
|
||||
"threesomes": ACTION_THREESOME,
|
||||
"threeway": ACTION_THREESOME,
|
||||
"three_way": ACTION_THREESOME,
|
||||
"group": ACTION_GROUP,
|
||||
"group_sex": ACTION_GROUP,
|
||||
"group_sex_orgy": ACTION_GROUP,
|
||||
"orgy": ACTION_GROUP,
|
||||
"manual": ACTION_MANUAL,
|
||||
"manual_stimulation": ACTION_MANUAL,
|
||||
"interaction": ACTION_FOREPLAY,
|
||||
"body_worship": ACTION_FOREPLAY,
|
||||
"body_worship_touching": ACTION_FOREPLAY,
|
||||
"foreplay_teasing": ACTION_FOREPLAY,
|
||||
"cumshot": ACTION_CLIMAX,
|
||||
"cumshot_climax": ACTION_CLIMAX,
|
||||
"orgasm_aftermath": ACTION_CLIMAX,
|
||||
"oral_sex": ACTION_ORAL,
|
||||
}
|
||||
text = aliases.get(text, text)
|
||||
return text if text in HARDCORE_ACTION_FAMILY_CHOICES else default
|
||||
|
||||
|
||||
def infer_hardcore_action_family(
|
||||
role_graph: str,
|
||||
hard_item: str,
|
||||
composition: str = "",
|
||||
axis_values: Any = None,
|
||||
*,
|
||||
is_climax: bool | None = None,
|
||||
) -> str:
|
||||
axis_text = axis_values_text(axis_values)
|
||||
if is_climax is None:
|
||||
is_climax = is_climax_text(role_graph, hard_item, composition, axis_text)
|
||||
if is_climax:
|
||||
return ACTION_CLIMAX
|
||||
if is_foreplay_text(role_graph, hard_item, composition, axis_text):
|
||||
return ACTION_FOREPLAY
|
||||
if is_outercourse_text(role_graph, hard_item, composition, axis_text):
|
||||
return ACTION_OUTERCOURSE
|
||||
if is_oral_text(role_graph, hard_item, composition, axis_text):
|
||||
return ACTION_ORAL
|
||||
if is_vaginal_penetration_text(role_graph, hard_item, composition, axis_text):
|
||||
return ACTION_PENETRATION
|
||||
if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_text):
|
||||
return ACTION_TOY_DOUBLE
|
||||
return ACTION_DEFAULT
|
||||
|
||||
|
||||
def source_hardcore_action_family(
|
||||
source_family: Any,
|
||||
role_graph: str,
|
||||
hard_item: str,
|
||||
composition: str = "",
|
||||
axis_values: Any = None,
|
||||
) -> str:
|
||||
inferred = infer_hardcore_action_family(role_graph, hard_item, composition, axis_values)
|
||||
if inferred in (ACTION_CLIMAX, ACTION_TOY_DOUBLE):
|
||||
return inferred
|
||||
family = re.sub(r"[^a-z0-9]+", "_", str(source_family or "").strip().lower()).strip("_")
|
||||
family = {
|
||||
"penetration": "penetrative",
|
||||
"penetrative_sex": "penetrative",
|
||||
"outer_course": "outercourse",
|
||||
"outercourse_sex": "outercourse",
|
||||
"manual_stimulation": "manual",
|
||||
"foreplay_teasing": "foreplay",
|
||||
"body_worship": "interaction",
|
||||
"body_worship_touching": "interaction",
|
||||
"clothing_position_transitions": "interaction",
|
||||
"dominant_guidance": "interaction",
|
||||
"camera_performance": "interaction",
|
||||
"group_coordination": "interaction",
|
||||
"cumshot": "climax",
|
||||
"cumshot_climax": "climax",
|
||||
"oral_sex": "oral",
|
||||
}.get(family, family)
|
||||
source_mapping = {
|
||||
"penetrative": ACTION_PENETRATION,
|
||||
"anal": ACTION_ANAL,
|
||||
"foreplay": ACTION_FOREPLAY,
|
||||
"interaction": ACTION_FOREPLAY,
|
||||
"manual": ACTION_MANUAL,
|
||||
"oral": ACTION_ORAL,
|
||||
"outercourse": ACTION_OUTERCOURSE,
|
||||
"threesome": ACTION_THREESOME,
|
||||
"group": ACTION_GROUP,
|
||||
"climax": ACTION_CLIMAX,
|
||||
}
|
||||
return source_mapping.get(family, inferred)
|
||||
@@ -0,0 +1,879 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from string import Formatter
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
HARDCORE_POSITION_FAMILY_CHOICES = [
|
||||
"any",
|
||||
"penetrative",
|
||||
"foreplay",
|
||||
"interaction",
|
||||
"manual",
|
||||
"oral",
|
||||
"outercourse",
|
||||
"anal",
|
||||
"climax",
|
||||
"threesome",
|
||||
"group",
|
||||
]
|
||||
HARDCORE_POSITION_FOCUS_CHOICES = [
|
||||
"keep_pool",
|
||||
"penetration_only",
|
||||
"foreplay_only",
|
||||
"interaction_only",
|
||||
"manual_only",
|
||||
"oral_only",
|
||||
"outercourse_only",
|
||||
"anal_only",
|
||||
"climax_only",
|
||||
"threesome_only",
|
||||
"group_only",
|
||||
]
|
||||
HARDCORE_POSITION_KEY_CHOICES = [
|
||||
"missionary",
|
||||
"cowgirl",
|
||||
"reverse_cowgirl",
|
||||
"doggy",
|
||||
"bent_over",
|
||||
"face_down_ass_up",
|
||||
"standing",
|
||||
"side_lying",
|
||||
"edge_supported",
|
||||
"kneeling",
|
||||
"lotus_lap",
|
||||
"face_sitting",
|
||||
"sixty_nine",
|
||||
"reclining_oral",
|
||||
"straddled_oral",
|
||||
"spread_leg_oral",
|
||||
"chair_oral",
|
||||
"kissing",
|
||||
"caressing",
|
||||
"breast_touch",
|
||||
"face_touch",
|
||||
"undressing",
|
||||
"body_worship",
|
||||
"nipple_play",
|
||||
"ass_grab",
|
||||
"thigh_kissing",
|
||||
"hair_holding",
|
||||
"wrist_pinning",
|
||||
"dirty_talk",
|
||||
"position_transition",
|
||||
"guided_positioning",
|
||||
"camera_showing",
|
||||
"watching",
|
||||
"aftercare",
|
||||
"cleanup",
|
||||
"fingering",
|
||||
"clit_rubbing",
|
||||
"mutual_masturbation",
|
||||
"boobjob",
|
||||
"testicle_sucking",
|
||||
"penis_licking",
|
||||
"handjob",
|
||||
"footjob",
|
||||
"open_thighs",
|
||||
"front_back",
|
||||
]
|
||||
HARDCORE_POSITION_FAMILY_SUBCATEGORIES = {
|
||||
"any": [
|
||||
"penetrative_sex",
|
||||
"foreplay_teasing",
|
||||
"body_worship_touching",
|
||||
"clothing_position_transitions",
|
||||
"dominant_guidance",
|
||||
"camera_performance",
|
||||
"manual_stimulation",
|
||||
"oral_sex",
|
||||
"outercourse_sex",
|
||||
"anal_double_penetration",
|
||||
"threesomes",
|
||||
"group_coordination",
|
||||
"group_sex_orgy",
|
||||
"cumshot_climax",
|
||||
"aftercare_cleanup",
|
||||
],
|
||||
"penetrative": ["penetrative_sex"],
|
||||
"foreplay": ["foreplay_teasing"],
|
||||
"interaction": [
|
||||
"foreplay_teasing",
|
||||
"body_worship_touching",
|
||||
"clothing_position_transitions",
|
||||
"dominant_guidance",
|
||||
"camera_performance",
|
||||
"group_coordination",
|
||||
"aftercare_cleanup",
|
||||
],
|
||||
"manual": ["manual_stimulation"],
|
||||
"oral": ["oral_sex"],
|
||||
"outercourse": ["outercourse_sex", "manual_stimulation"],
|
||||
"anal": ["anal_double_penetration"],
|
||||
"climax": ["cumshot_climax"],
|
||||
"threesome": ["threesomes"],
|
||||
"group": ["group_sex_orgy"],
|
||||
}
|
||||
HARDCORE_POSITION_KEY_MATCHES = {
|
||||
"missionary": ("missionary", "above her", "under her"),
|
||||
"cowgirl": ("cowgirl", "straddling", "straddles", "on top", "squatting on top"),
|
||||
"reverse_cowgirl": ("reverse cowgirl", "facing away"),
|
||||
"doggy": ("doggy", "all fours", "rear-entry", "from behind"),
|
||||
"bent_over": ("bent-over", "bent over", "hips raised"),
|
||||
"face_down_ass_up": ("face-down", "ass-up"),
|
||||
"standing": ("standing", "stands", "braced standing"),
|
||||
"side_lying": ("side-lying", "side lying", "spooning", "on the side", "on her side"),
|
||||
"edge_supported": ("edge-of-bed", "edge of bed", "bed edge", "raised edge", "edge-supported"),
|
||||
"kneeling": ("kneeling", "kneels", "kneeling center"),
|
||||
"lotus_lap": ("lotus", "lap", "seated in a partner's lap"),
|
||||
"face_sitting": ("face-sitting", "face sitting"),
|
||||
"sixty_nine": ("sixty-nine", "69"),
|
||||
"reclining_oral": ("reclining cunnilingus",),
|
||||
"straddled_oral": ("straddled oral",),
|
||||
"spread_leg_oral": ("spread-leg", "spread leg", "reclining cunnilingus"),
|
||||
"chair_oral": ("chair oral",),
|
||||
"kissing": ("kiss", "kissing", "mouth-to-mouth", "mouth to mouth", "lips pressed"),
|
||||
"caressing": ("caress", "caressing", "hands roaming", "stroking skin", "hands sliding"),
|
||||
"breast_touch": ("breast", "breasts", "nipple", "cupping breasts", "touching breasts"),
|
||||
"face_touch": ("face", "cheek", "jaw", "chin", "hand on the cheek", "fingers under the chin"),
|
||||
"undressing": ("undressing", "removing clothing", "removing clothes", "pulling clothing", "sliding straps", "unbuttoning"),
|
||||
"body_worship": ("body worship", "worship", "kissing down", "mouth on skin", "kissing the body"),
|
||||
"nipple_play": ("nipple", "nipples", "licking nipples", "sucking nipples", "nipple play"),
|
||||
"ass_grab": ("ass grab", "ass-grab", "ass grabbing", "hand on the ass", "squeezing the ass"),
|
||||
"thigh_kissing": ("thigh kiss", "thigh kissing", "kissing thighs", "mouth on inner thighs"),
|
||||
"hair_holding": ("hair holding", "hair held", "holding hair", "hair pulled back"),
|
||||
"wrist_pinning": ("wrist", "wrists", "pinning wrists", "wrists pinned", "hands pinned"),
|
||||
"dirty_talk": ("dirty talk", "whispering", "mouth near the ear", "telling", "verbal teasing"),
|
||||
"position_transition": ("transition", "turning around", "pulling onto the bed", "moving into position", "position change"),
|
||||
"guided_positioning": ("guiding", "guided", "guides", "lifting legs", "spreading thighs", "pulling hips", "turning the body"),
|
||||
"camera_showing": ("camera", "showing to camera", "presenting to camera", "spread open for camera", "creator-shot"),
|
||||
"watching": ("watching", "voyeur", "waiting turn", "partner watches", "onlooker"),
|
||||
"aftercare": ("aftercare", "cuddling", "kissing after", "holding close", "post-sex"),
|
||||
"cleanup": ("cleanup", "wiping", "cleaning", "towel", "wet cloth"),
|
||||
"fingering": ("fingering", "fingers inside", "fingers in pussy", "finger stimulation"),
|
||||
"clit_rubbing": ("clit", "clitoris", "clit rubbing", "rubbing the clit", "fingers on clit"),
|
||||
"mutual_masturbation": ("mutual masturbation", "both touching themselves", "masturbating together", "hands on their own bodies"),
|
||||
"boobjob": ("boobjob", "titjob", "breast-sex", "breast sex"),
|
||||
"testicle_sucking": ("testicle", "balls-licking", "balls licking", "balls and mouth"),
|
||||
"penis_licking": ("penis-licking", "penis licking", "tongue along", "tongue licking"),
|
||||
"handjob": ("handjob", "hand job", "stroking the penis", "hand stroking", "manual stimulation"),
|
||||
"footjob": ("footjob", "soles", "toes curled", "feet stroking"),
|
||||
"open_thighs": ("thighs open", "legs spread", "open thighs", "legs open", "reclining with thighs open"),
|
||||
"front_back": ("front-and-back", "front and back", "one behind and one in front", "between two partners"),
|
||||
}
|
||||
HARDCORE_POSITION_AXIS_KEYS = {
|
||||
"position",
|
||||
"body_position",
|
||||
"body_arrangement",
|
||||
"arrangement",
|
||||
"tease_act",
|
||||
"touch_detail",
|
||||
"manual_act",
|
||||
"manual_detail",
|
||||
"worship_act",
|
||||
"transition_act",
|
||||
"control_act",
|
||||
"performance_act",
|
||||
"coordination_act",
|
||||
"aftercare_act",
|
||||
"cleanup_detail",
|
||||
}
|
||||
HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY = {
|
||||
"penetrative_sex": "penetrative",
|
||||
"foreplay_teasing": "foreplay",
|
||||
"body_worship_touching": "interaction",
|
||||
"clothing_position_transitions": "interaction",
|
||||
"dominant_guidance": "interaction",
|
||||
"camera_performance": "interaction",
|
||||
"manual_stimulation": "manual",
|
||||
"oral_sex": "oral",
|
||||
"outercourse_sex": "outercourse",
|
||||
"anal_double_penetration": "anal",
|
||||
"threesomes": "threesome",
|
||||
"group_coordination": "interaction",
|
||||
"group_sex_orgy": "group",
|
||||
"cumshot_climax": "climax",
|
||||
"aftercare_cleanup": "interaction",
|
||||
}
|
||||
FOCUS_FAMILY_BY_KEY = {
|
||||
"penetration_only": "penetrative",
|
||||
"foreplay_only": "foreplay",
|
||||
"interaction_only": "interaction",
|
||||
"manual_only": "manual",
|
||||
"oral_only": "oral",
|
||||
"outercourse_only": "outercourse",
|
||||
"anal_only": "anal",
|
||||
"climax_only": "climax",
|
||||
"threesome_only": "threesome",
|
||||
"group_only": "group",
|
||||
}
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def _list_from(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
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 _metadata_tokens(item: Any, keys: tuple[str, ...]) -> set[str]:
|
||||
if not isinstance(item, dict):
|
||||
return set()
|
||||
tokens: set[str] = set()
|
||||
for key in keys:
|
||||
for value in _list_from(item.get(key)):
|
||||
token = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
||||
if token and token != "any":
|
||||
tokens.add(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _entry_position_keys(item: Any) -> list[str]:
|
||||
if not isinstance(item, dict):
|
||||
return []
|
||||
values: list[Any] = []
|
||||
if item.get("position_keys") is not None:
|
||||
values.extend(_list_from(item.get("position_keys")))
|
||||
if item.get("position_key") is not None:
|
||||
values.append(item.get("position_key"))
|
||||
return normalize_hardcore_position_values(values)
|
||||
|
||||
|
||||
def hardcore_position_family_choices() -> list[str]:
|
||||
return list(HARDCORE_POSITION_FAMILY_CHOICES)
|
||||
|
||||
|
||||
def hardcore_position_focus_choices() -> list[str]:
|
||||
return list(HARDCORE_POSITION_FOCUS_CHOICES)
|
||||
|
||||
|
||||
def hardcore_position_key_choices() -> list[str]:
|
||||
return list(HARDCORE_POSITION_KEY_CHOICES)
|
||||
|
||||
|
||||
def normalize_hardcore_position_family(value: Any, default: str = "any") -> str:
|
||||
text = re.sub(r"[^a-z0-9]+", "_", str(value or default).strip().lower()).strip("_")
|
||||
aliases = {
|
||||
"penetration": "penetrative",
|
||||
"penetrative_sex": "penetrative",
|
||||
"penetration_sex": "penetrative",
|
||||
"vaginal": "penetrative",
|
||||
"vaginal_penetration": "penetrative",
|
||||
"foreplay_teasing": "foreplay",
|
||||
"body_worship": "interaction",
|
||||
"body_worship_touching": "interaction",
|
||||
"clothing_position_transitions": "interaction",
|
||||
"dominant_guidance": "interaction",
|
||||
"camera_performance": "interaction",
|
||||
"group_coordination": "interaction",
|
||||
"aftercare_cleanup": "interaction",
|
||||
"manual_stimulation": "manual",
|
||||
"oral_sex": "oral",
|
||||
"outer_course": "outercourse",
|
||||
"outercourse_sex": "outercourse",
|
||||
"anal_double_penetration": "anal",
|
||||
"three_some": "threesome",
|
||||
"threesomes": "threesome",
|
||||
"group_sex": "group",
|
||||
"group_sex_orgy": "group",
|
||||
"orgy": "group",
|
||||
"cumshot": "climax",
|
||||
"cumshot_climax": "climax",
|
||||
"orgasm_aftermath": "climax",
|
||||
}
|
||||
text = aliases.get(text, text)
|
||||
return text if text in HARDCORE_POSITION_FAMILY_CHOICES else default
|
||||
|
||||
|
||||
def normalize_hardcore_position_values(values: Any) -> list[str]:
|
||||
raw_values = _list_from(values)
|
||||
selected: list[str] = []
|
||||
for value in raw_values:
|
||||
text = str(value or "").strip()
|
||||
if not text or text == "any":
|
||||
continue
|
||||
normalized = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
||||
if normalized in HARDCORE_POSITION_KEY_CHOICES and normalized not in selected:
|
||||
selected.append(normalized)
|
||||
return selected
|
||||
|
||||
|
||||
def empty_hardcore_position_config() -> dict[str, Any]:
|
||||
return {
|
||||
"config_type": "hardcore_position",
|
||||
"enabled": False,
|
||||
"family": "any",
|
||||
"positions": [],
|
||||
"require_position": False,
|
||||
"allow_toys": True,
|
||||
"allow_double": True,
|
||||
"allow_penetration": True,
|
||||
"allow_foreplay": True,
|
||||
"allow_interaction": True,
|
||||
"allow_manual": True,
|
||||
"allow_oral": True,
|
||||
"allow_outercourse": True,
|
||||
"allow_anal": True,
|
||||
"allow_climax": True,
|
||||
}
|
||||
|
||||
|
||||
def parse_hardcore_position_config(value: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not value:
|
||||
return empty_hardcore_position_config()
|
||||
if isinstance(value, dict):
|
||||
raw = value
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(value))
|
||||
except json.JSONDecodeError:
|
||||
return empty_hardcore_position_config()
|
||||
if not isinstance(raw, dict):
|
||||
return empty_hardcore_position_config()
|
||||
parsed = {**empty_hardcore_position_config(), **raw}
|
||||
parsed["enabled"] = bool(parsed.get("enabled", True))
|
||||
parsed["family"] = normalize_hardcore_position_family(parsed.get("family"))
|
||||
parsed["positions"] = normalize_hardcore_position_values(parsed.get("positions"))
|
||||
parsed["require_position"] = not _is_false(parsed.get("require_position", False))
|
||||
for key in (
|
||||
"allow_toys",
|
||||
"allow_double",
|
||||
"allow_penetration",
|
||||
"allow_foreplay",
|
||||
"allow_interaction",
|
||||
"allow_manual",
|
||||
"allow_oral",
|
||||
"allow_outercourse",
|
||||
"allow_anal",
|
||||
"allow_climax",
|
||||
):
|
||||
parsed[key] = not _is_false(parsed.get(key, True))
|
||||
return parsed
|
||||
|
||||
|
||||
def hardcore_position_summary(config: dict[str, Any]) -> str:
|
||||
if not config.get("enabled"):
|
||||
return "hardcore position unrestricted"
|
||||
parts = [f"family={config.get('family', 'any')}"]
|
||||
positions = config.get("positions") or []
|
||||
if positions:
|
||||
parts.append("positions=" + ",".join(positions))
|
||||
elif config.get("require_position"):
|
||||
parts.append("position_templates=required")
|
||||
disabled = [
|
||||
label
|
||||
for key, label in (
|
||||
("allow_toys", "toys"),
|
||||
("allow_double", "double"),
|
||||
("allow_penetration", "penetration"),
|
||||
("allow_foreplay", "foreplay"),
|
||||
("allow_interaction", "interaction"),
|
||||
("allow_manual", "manual"),
|
||||
("allow_oral", "oral"),
|
||||
("allow_outercourse", "outercourse"),
|
||||
("allow_anal", "anal"),
|
||||
("allow_climax", "climax"),
|
||||
)
|
||||
if not config.get(key, True)
|
||||
]
|
||||
if disabled:
|
||||
parts.append("blocked=" + ",".join(disabled))
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def build_hardcore_position_pool_json(
|
||||
hardcore_position_config: str | dict[str, Any] | None = "",
|
||||
combine_mode: str = "replace",
|
||||
family: str = "any",
|
||||
selected_positions: list[str] | tuple[str, ...] | str | None = None,
|
||||
) -> str:
|
||||
base = parse_hardcore_position_config(hardcore_position_config)
|
||||
if combine_mode == "replace":
|
||||
base = {**empty_hardcore_position_config(), "enabled": True}
|
||||
else:
|
||||
base["enabled"] = True
|
||||
base["family"] = normalize_hardcore_position_family(family, base.get("family", "any"))
|
||||
selected = normalize_hardcore_position_values(selected_positions)
|
||||
if combine_mode == "add":
|
||||
existing = list(base.get("positions") or [])
|
||||
for value in selected:
|
||||
if value not in existing:
|
||||
existing.append(value)
|
||||
base["positions"] = existing
|
||||
else:
|
||||
base["positions"] = selected
|
||||
base["require_position"] = bool(base.get("require_position")) or bool(base["positions"]) or base["family"] != "any"
|
||||
base["summary"] = hardcore_position_summary(base)
|
||||
return json.dumps(base, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def build_hardcore_action_filter_json(
|
||||
hardcore_position_config: str | dict[str, Any] | None = "",
|
||||
focus: str = "keep_pool",
|
||||
allow_toys: bool = False,
|
||||
allow_double: bool = False,
|
||||
allow_penetration: bool = True,
|
||||
allow_foreplay: bool = True,
|
||||
allow_interaction: bool = True,
|
||||
allow_manual: bool = True,
|
||||
allow_oral: bool = True,
|
||||
allow_outercourse: bool = True,
|
||||
allow_anal: bool = True,
|
||||
allow_climax: bool = True,
|
||||
) -> str:
|
||||
config = parse_hardcore_position_config(hardcore_position_config)
|
||||
config["enabled"] = True
|
||||
focus = str(focus or "keep_pool").strip()
|
||||
focus_family = FOCUS_FAMILY_BY_KEY.get(focus)
|
||||
if focus_family:
|
||||
config["family"] = focus_family
|
||||
config["allow_toys"] = bool(allow_toys)
|
||||
config["allow_double"] = bool(allow_double)
|
||||
config["allow_penetration"] = bool(allow_penetration)
|
||||
config["allow_foreplay"] = bool(allow_foreplay)
|
||||
config["allow_interaction"] = bool(allow_interaction)
|
||||
config["allow_manual"] = bool(allow_manual)
|
||||
config["allow_oral"] = bool(allow_oral)
|
||||
config["allow_outercourse"] = bool(allow_outercourse)
|
||||
config["allow_anal"] = bool(allow_anal)
|
||||
config["allow_climax"] = bool(allow_climax)
|
||||
|
||||
if not focus_family and config["family"] != "any":
|
||||
enabled_action_families = {
|
||||
family
|
||||
for enabled, family in (
|
||||
(config["allow_penetration"], "penetrative"),
|
||||
(config["allow_foreplay"], "foreplay"),
|
||||
(config["allow_interaction"], "interaction"),
|
||||
(config["allow_manual"], "manual"),
|
||||
(config["allow_oral"], "oral"),
|
||||
(config["allow_outercourse"], "outercourse"),
|
||||
(config["allow_anal"], "anal"),
|
||||
(config["allow_climax"], "climax"),
|
||||
)
|
||||
if enabled
|
||||
}
|
||||
if config["family"] in enabled_action_families and len(enabled_action_families) > 1:
|
||||
config["family"] = "any"
|
||||
|
||||
if focus == "foreplay_only":
|
||||
config["allow_foreplay"] = True
|
||||
config["allow_interaction"] = True
|
||||
elif focus == "interaction_only":
|
||||
config["allow_interaction"] = True
|
||||
config["allow_foreplay"] = True
|
||||
elif focus == "manual_only":
|
||||
config["allow_manual"] = True
|
||||
elif focus == "oral_only":
|
||||
config["allow_oral"] = True
|
||||
config["allow_penetration"] = False
|
||||
elif focus == "outercourse_only":
|
||||
config["allow_outercourse"] = True
|
||||
config["allow_oral"] = False
|
||||
config["allow_penetration"] = False
|
||||
elif focus == "anal_only":
|
||||
config["allow_anal"] = True
|
||||
config["allow_penetration"] = True
|
||||
elif focus == "climax_only":
|
||||
config["allow_climax"] = True
|
||||
config["summary"] = hardcore_position_summary(config)
|
||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
def hardcore_position_config_active(config: dict[str, Any]) -> bool:
|
||||
return bool(config.get("enabled"))
|
||||
|
||||
|
||||
def hardcore_position_template_required(config: dict[str, Any]) -> bool:
|
||||
if not hardcore_position_config_active(config):
|
||||
return False
|
||||
return (
|
||||
bool(config.get("require_position"))
|
||||
or bool(config.get("positions"))
|
||||
or normalize_hardcore_position_family(config.get("family")) != "any"
|
||||
)
|
||||
|
||||
|
||||
def hardcore_allowed_subcategory_slugs(config: dict[str, Any]) -> set[str]:
|
||||
family = normalize_hardcore_position_family(config.get("family"))
|
||||
base_allowed = set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES.get(family, HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"]))
|
||||
allowed = set(base_allowed)
|
||||
if not config.get("allow_penetration", True):
|
||||
allowed.difference_update({"penetrative_sex", "anal_double_penetration", "threesomes", "group_sex_orgy"})
|
||||
if not config.get("allow_foreplay", True):
|
||||
allowed.discard("foreplay_teasing")
|
||||
if not config.get("allow_interaction", True):
|
||||
allowed.difference_update(
|
||||
{
|
||||
"foreplay_teasing",
|
||||
"body_worship_touching",
|
||||
"clothing_position_transitions",
|
||||
"dominant_guidance",
|
||||
"camera_performance",
|
||||
"group_coordination",
|
||||
"aftercare_cleanup",
|
||||
}
|
||||
)
|
||||
if not config.get("allow_manual", True):
|
||||
allowed.discard("manual_stimulation")
|
||||
if not config.get("allow_oral", True):
|
||||
allowed.discard("oral_sex")
|
||||
if not config.get("allow_outercourse", True):
|
||||
allowed.discard("outercourse_sex")
|
||||
if not config.get("allow_anal", True):
|
||||
allowed.discard("anal_double_penetration")
|
||||
if not config.get("allow_climax", True):
|
||||
allowed.discard("cumshot_climax")
|
||||
if not config.get("allow_double", True) and family == "anal":
|
||||
allowed.add("anal_double_penetration")
|
||||
if allowed:
|
||||
return allowed
|
||||
if family != "any":
|
||||
return base_allowed
|
||||
return set(HARDCORE_POSITION_FAMILY_SUBCATEGORIES["any"])
|
||||
|
||||
|
||||
def is_hardcore_sexual_category(category: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(category.get("slug") or "").strip() == "hardcore_sexual_poses"
|
||||
or str(category.get("name") or "").strip().lower() == "hardcore sexual poses"
|
||||
)
|
||||
|
||||
|
||||
def hardcore_text_blocked_by_action(text: str, axis_name: str, config: dict[str, Any]) -> bool:
|
||||
text = str(text or "").lower()
|
||||
axis_name = str(axis_name or "").lower()
|
||||
if not config.get("allow_toys", True) and any(term in text for term in ("toy", "dildo", "strap-on", "strap on")):
|
||||
return True
|
||||
if not config.get("allow_double", True) and (
|
||||
axis_name == "double_act"
|
||||
or any(term in text for term in ("double penetration", "double-penetration", "front-and-back", "front and back", "second penetration", "both sides", "two partners penetrating", "multiple penetrations"))
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_anal", True) and (
|
||||
axis_name == "anal_act"
|
||||
or any(term in text for term in (" anal", "anal sex", "anal penetration", "anus", "rear-entry anal", "penis entering ass", "thrusts into her ass", "thrusts into his ass"))
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_oral", True) and (
|
||||
axis_name in ("oral_act", "oral_detail")
|
||||
or any(term in text for term in ("oral sex", "mouth on genitals", "mouth on pussy", "blowjob", "cunnilingus", "tongue on pussy", "deepthroat", "fellatio"))
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_outercourse", True) and (
|
||||
axis_name in ("outer_act", "contact_detail", "texture_detail")
|
||||
or any(term in text for term in ("boobjob", "titjob", "breast sex", "breast-sex", "testicle", "balls", "penis licking", "penis-licking", "footjob", "soles", "toes"))
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_penetration", True) and (
|
||||
axis_name in ("penetration_act", "penetration_detail", "anal_act", "double_act", "thrust_detail")
|
||||
or any(term in text for term in ("penetration", "penetrative", "thrust", "penis entering", "vaginal sex", "anal sex"))
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_foreplay", True) and (
|
||||
axis_name in ("tease_act", "touch_detail", "clothing_detail", "foreplay_detail", "face_detail", "body_contact", "mood_detail")
|
||||
or any(
|
||||
term in text
|
||||
for term in (
|
||||
"kiss",
|
||||
"kissing",
|
||||
"mouth-to-mouth",
|
||||
"caress",
|
||||
"caressing",
|
||||
"stroking skin",
|
||||
"hands roaming",
|
||||
"touching breasts",
|
||||
"cupping breasts",
|
||||
"hand on the cheek",
|
||||
"fingers under the chin",
|
||||
"undressing",
|
||||
"removing clothing",
|
||||
"removing clothes",
|
||||
"pulling clothing",
|
||||
"sliding straps",
|
||||
"unbuttoning",
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_interaction", True) and (
|
||||
axis_name
|
||||
in (
|
||||
"tease_act",
|
||||
"touch_detail",
|
||||
"clothing_detail",
|
||||
"foreplay_detail",
|
||||
"face_detail",
|
||||
"body_contact",
|
||||
"mood_detail",
|
||||
"worship_act",
|
||||
"transition_act",
|
||||
"control_act",
|
||||
"performance_act",
|
||||
"coordination_act",
|
||||
"aftercare_act",
|
||||
"cleanup_detail",
|
||||
)
|
||||
or any(
|
||||
term in text
|
||||
for term in (
|
||||
"kiss",
|
||||
"kissing",
|
||||
"caress",
|
||||
"body worship",
|
||||
"nipple",
|
||||
"ass grab",
|
||||
"thigh",
|
||||
"hair holding",
|
||||
"wrists",
|
||||
"dirty talk",
|
||||
"whispering",
|
||||
"undressing",
|
||||
"position transition",
|
||||
"guided",
|
||||
"camera",
|
||||
"watching",
|
||||
"aftercare",
|
||||
"cleanup",
|
||||
"wiping",
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_manual", True) and (
|
||||
axis_name in ("manual_act", "manual_detail")
|
||||
or any(
|
||||
term in text
|
||||
for term in (
|
||||
"fingering",
|
||||
"fingers inside",
|
||||
"clit",
|
||||
"clitoris",
|
||||
"manual stimulation",
|
||||
"mutual masturbation",
|
||||
"masturbating together",
|
||||
"fingers on pussy",
|
||||
"fingers on clit",
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
if not config.get("allow_climax", True) and (
|
||||
axis_name in ("climax_act", "climax_hint", "climax_detail", "fluid_detail", "fluid_location")
|
||||
or any(term in text for term in ("climax", "cum", "semen", "ejaculat", "creampie", "post-orgasm", "post-penetration"))
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def hardcore_entry_blocked_by_action(entry: Any, axis_name: str, config: dict[str, Any]) -> bool:
|
||||
action_tokens = _metadata_tokens(entry, ("action_family", "action_type"))
|
||||
family_tokens = _metadata_tokens(entry, ("position_family", "family"))
|
||||
position_keys = set(_entry_position_keys(entry))
|
||||
route_tokens = action_tokens | family_tokens
|
||||
|
||||
if not config.get("allow_toys", True) and action_tokens & {"toy", "toy_double"}:
|
||||
return True
|
||||
if not config.get("allow_double", True) and (action_tokens & {"double", "toy_double"} or "front_back" in position_keys):
|
||||
return True
|
||||
if not config.get("allow_anal", True) and "anal" in route_tokens:
|
||||
return True
|
||||
if not config.get("allow_oral", True) and "oral" in route_tokens:
|
||||
return True
|
||||
if not config.get("allow_outercourse", True) and "outercourse" in route_tokens:
|
||||
return True
|
||||
if not config.get("allow_penetration", True) and route_tokens & {"penetration", "penetrative", "toy_double", "anal"}:
|
||||
return True
|
||||
if not config.get("allow_foreplay", True) and "foreplay" in route_tokens:
|
||||
return True
|
||||
if not config.get("allow_interaction", True) and "interaction" in route_tokens:
|
||||
return True
|
||||
if not config.get("allow_manual", True) and "manual" in route_tokens:
|
||||
return True
|
||||
if not config.get("allow_climax", True) and "climax" in route_tokens:
|
||||
return True
|
||||
return hardcore_text_blocked_by_action(_entry_text(entry), axis_name, config)
|
||||
|
||||
|
||||
def hardcore_position_entry_matches(entry: Any, config: dict[str, Any]) -> bool:
|
||||
positions = config.get("positions") or []
|
||||
if not positions:
|
||||
return True
|
||||
metadata_keys = _entry_position_keys(entry)
|
||||
if metadata_keys:
|
||||
return bool(set(metadata_keys) & set(positions))
|
||||
text = _entry_text(entry).lower()
|
||||
for position in positions:
|
||||
if any(term in text for term in HARDCORE_POSITION_KEY_MATCHES.get(position, ())):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def hardcore_position_entry_conflicts(entry: Any, config: dict[str, Any]) -> bool:
|
||||
selected = set(config.get("positions") or [])
|
||||
if not selected:
|
||||
return False
|
||||
metadata_keys = _entry_position_keys(entry)
|
||||
if metadata_keys:
|
||||
matched = set(metadata_keys)
|
||||
else:
|
||||
text = _entry_text(entry).lower()
|
||||
matched = {
|
||||
position
|
||||
for position, terms in HARDCORE_POSITION_KEY_MATCHES.items()
|
||||
if any(term in text for term in terms)
|
||||
}
|
||||
return bool(matched) and not bool(matched & selected)
|
||||
|
||||
|
||||
def hardcore_subcategory_supports_positions(subcategory: dict[str, Any], config: dict[str, Any]) -> bool:
|
||||
if not hardcore_position_template_required(config):
|
||||
return True
|
||||
axes = subcategory.get("item_axes")
|
||||
if not isinstance(axes, dict):
|
||||
return True
|
||||
for axis_name, values in axes.items():
|
||||
if str(axis_name) in HARDCORE_POSITION_AXIS_KEYS and any(
|
||||
hardcore_position_entry_matches(value, config)
|
||||
for value in _list_from(values)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def filter_hardcore_axis(axis_name: str, values: list[Any], config: dict[str, Any]) -> list[Any]:
|
||||
if not hardcore_position_config_active(config):
|
||||
return values
|
||||
filtered = [
|
||||
value
|
||||
for value in values
|
||||
if not hardcore_entry_blocked_by_action(value, axis_name, config)
|
||||
and not (axis_name not in HARDCORE_POSITION_AXIS_KEYS and hardcore_position_entry_conflicts(value, config))
|
||||
and (axis_name not in HARDCORE_POSITION_AXIS_KEYS or hardcore_position_entry_matches(value, config))
|
||||
]
|
||||
return filtered or values
|
||||
|
||||
|
||||
def filter_hardcore_templates(templates: list[Any], config: dict[str, Any]) -> list[Any]:
|
||||
if not hardcore_position_config_active(config):
|
||||
return templates
|
||||
filtered: list[Any] = []
|
||||
for template in templates:
|
||||
text = _entry_text(template)
|
||||
fields = {key for _, key, _, _ in Formatter().parse(text) if key}
|
||||
has_position_route = bool(fields & HARDCORE_POSITION_AXIS_KEYS) or bool(_entry_position_keys(template))
|
||||
blocked = hardcore_position_template_required(config) and not has_position_route
|
||||
blocked = blocked or hardcore_entry_blocked_by_action(template, "", config)
|
||||
blocked = blocked or any(hardcore_text_blocked_by_action(text, field, config) for field in fields)
|
||||
if not blocked:
|
||||
filtered.append(template)
|
||||
return filtered or templates
|
||||
|
||||
|
||||
def apply_hardcore_position_config_to_subcategory(
|
||||
subcategory: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not hardcore_position_config_active(config):
|
||||
return subcategory
|
||||
subcategory_copy = dict(subcategory)
|
||||
if "item_templates" in subcategory_copy:
|
||||
subcategory_copy["item_templates"] = filter_hardcore_templates(_list_from(subcategory_copy["item_templates"]), config)
|
||||
raw_axes = subcategory_copy.get("item_axes")
|
||||
if isinstance(raw_axes, dict):
|
||||
axes = {}
|
||||
for axis_name, values in raw_axes.items():
|
||||
axes[axis_name] = filter_hardcore_axis(str(axis_name), _list_from(values), config)
|
||||
subcategory_copy["item_axes"] = axes
|
||||
subcategory_copy["hardcore_position_config"] = config
|
||||
return subcategory_copy
|
||||
|
||||
|
||||
def filter_hardcore_categories_for_position(
|
||||
categories: list[dict[str, Any]],
|
||||
config: dict[str, Any],
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
compatible_entry: Callable[[dict[str, Any], int, int], bool],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not hardcore_position_config_active(config):
|
||||
return categories
|
||||
allowed = hardcore_allowed_subcategory_slugs(config)
|
||||
filtered_categories: list[dict[str, Any]] = []
|
||||
for category in categories:
|
||||
if not is_hardcore_sexual_category(category):
|
||||
filtered_categories.append(category)
|
||||
continue
|
||||
category_copy = dict(category)
|
||||
subcategories = [
|
||||
subcategory
|
||||
for subcategory in category.get("subcategories", [])
|
||||
if str(subcategory.get("slug") or "") in allowed
|
||||
and compatible_entry(subcategory, women_count, men_count)
|
||||
and hardcore_subcategory_supports_positions(subcategory, config)
|
||||
]
|
||||
if subcategories:
|
||||
category_copy["subcategories"] = subcategories
|
||||
filtered_categories.append(category_copy)
|
||||
return filtered_categories
|
||||
|
||||
|
||||
def hardcore_source_position_family(subcategory: dict[str, Any], config: dict[str, Any] | None = None) -> str:
|
||||
slug = str(subcategory.get("slug") or subcategory.get("name") or "").strip().lower()
|
||||
family = HARDCORE_SOURCE_FAMILY_BY_SUBCATEGORY.get(slug, "")
|
||||
if family:
|
||||
return family
|
||||
config_family = normalize_hardcore_position_family((config or {}).get("family"), "")
|
||||
return "" if config_family == "any" else config_family
|
||||
|
||||
|
||||
def hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = None) -> list[str]:
|
||||
text = item_axis_policy.context_text(*parts, axis_values=axis_values)
|
||||
if not text:
|
||||
return []
|
||||
keys: list[str] = []
|
||||
for key, tokens in HARDCORE_POSITION_KEY_MATCHES.items():
|
||||
if any(token in text for token in tokens):
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
_normalize_hardcore_position_family = normalize_hardcore_position_family
|
||||
_normalize_hardcore_position_values = normalize_hardcore_position_values
|
||||
_empty_hardcore_position_config = empty_hardcore_position_config
|
||||
_parse_hardcore_position_config = parse_hardcore_position_config
|
||||
_hardcore_position_summary = hardcore_position_summary
|
||||
_hardcore_position_config_active = hardcore_position_config_active
|
||||
_hardcore_position_template_required = hardcore_position_template_required
|
||||
_hardcore_allowed_subcategory_slugs = hardcore_allowed_subcategory_slugs
|
||||
_hardcore_source_position_family = hardcore_source_position_family
|
||||
_hardcore_position_keys = hardcore_position_keys
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str:
|
||||
return item_axis_policy.context_text(item_text, axis_values=item_axis_values)
|
||||
|
||||
|
||||
def _anal_position_graph(woman: str, man: str, context: str) -> str:
|
||||
if "bent-over" in context or "bent over" in context:
|
||||
return f"{woman} is bent forward with hips raised while {man} stands behind her and thrusts his penis into her ass."
|
||||
if "face-down" in context:
|
||||
return f"{woman} lies face-down with ass raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
if "doggy" in context or "rear-entry" in context:
|
||||
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
if "standing" in context:
|
||||
return f"{woman} stands braced with hips angled back while {man} stands behind her and thrusts his penis into her ass."
|
||||
if "spooning" in context or "side-lying" in context:
|
||||
return f"{woman} lies on her side with thighs parted while {man} presses behind her and thrusts his penis into her ass."
|
||||
if "edge-of-bed" in context or "edge of bed" in context or "bed edge" in context or "edge-supported" in context:
|
||||
return f"{woman} lies near a raised edge with hips exposed while {man} kneels behind her and thrusts his penis into her ass."
|
||||
if "kneeling" in context:
|
||||
return f"{woman} kneels forward with hips raised while {man} kneels behind her and thrusts his penis into her ass."
|
||||
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
|
||||
|
||||
def _two_person_double_graph(woman: str, man: str, context: str) -> str:
|
||||
if "bent-over" in context or "bent over" in context:
|
||||
return f"{woman} is bent forward with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
if "face-down" in context:
|
||||
return f"{woman} lies face-down with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
if "standing" in context:
|
||||
return f"{woman} stands braced with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
if "kneeling" in context:
|
||||
return f"{woman} kneels forward with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and thrusts his penis into her ass."
|
||||
|
||||
|
||||
def build_anal_or_double_role_graph(
|
||||
woman: str,
|
||||
man: str,
|
||||
third: str,
|
||||
people_count: int,
|
||||
item_text: str,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
context = _context_text(item_text, item_axis_values)
|
||||
if "double" in context or "toy" in context:
|
||||
if people_count >= 3 and third:
|
||||
return f"{man} thrusts his penis into {woman} while {third} adds a second penetration point from the front."
|
||||
return _two_person_double_graph(woman, man, context)
|
||||
if people_count >= 3 and third:
|
||||
return f"{man} thrusts his penis into {woman} while {third} gives oral contact from the front."
|
||||
return _anal_position_graph(woman, man, context)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str:
|
||||
return item_axis_policy.context_text(item_text, axis_values=item_axis_values)
|
||||
|
||||
|
||||
def _mentions_ass(text: str) -> bool:
|
||||
return bool(
|
||||
re.search(
|
||||
r"\bass\b|ass[- ](?:up|raised|exposed|lifted)|spread cheeks|lower back and ass|cum (?:on|dripping from) ass|pussy, ass|ass and",
|
||||
text,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_climax_role_graph(
|
||||
woman: str,
|
||||
man: str,
|
||||
third: str = "",
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
context = _context_text(item_text, item_axis_values)
|
||||
if "lying between two partners" in context and third:
|
||||
return f"{woman} lies between {man} and {third}, with {man} under her hips and {third} positioned above her torso as visible semen lands on her body."
|
||||
if "held between front-and-back partners" in context and third:
|
||||
return f"{woman} is held between {man} behind her and {third} in front of her as visible semen lands across her body."
|
||||
if "kneeling between standing partners" in context and third:
|
||||
return f"{woman} kneels between {man} and {third} while both stand close around her face and torso for visible ejaculation."
|
||||
if "side-lying with thighs parted" in context:
|
||||
return f"{woman} lies on her side with thighs parted while {man} kneels beside her hips and ejaculates semen across her thighs and pussy."
|
||||
if "sitting on the edge of the bed" in context:
|
||||
return f"{woman} sits on the edge of the bed with knees spread while {man} stands close between her legs and ejaculates semen across her body."
|
||||
if "lying at the bed edge with thighs open" in context:
|
||||
return f"{woman} lies at the bed edge with thighs open while {man} kneels between her legs and ejaculates semen across her pussy and thighs."
|
||||
if "reclining with thighs open" in context or "lying on the back with legs spread" in context:
|
||||
return f"{woman} lies on her back with thighs open while {man} kneels between her legs and ejaculates semen across her pussy and thighs."
|
||||
if "on all fours with hips raised" in context:
|
||||
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and ejaculates semen across her ass, thighs, and lower back."
|
||||
if "face-down ass-up" in context:
|
||||
return f"{woman} lies face-down with ass raised while {man} is positioned behind her and ejaculates semen across her lower back and ass."
|
||||
if "bent over with ass raised" in context or "bent over" in context:
|
||||
return f"{woman} is bent forward with hips raised while {man} is positioned behind her, visible semen across her lower back, ass, and thighs."
|
||||
if "kneeling with mouth open" in context:
|
||||
return f"{woman} kneels in front of {man} at hip height while {man} ejaculates semen onto her face, lips, and chest."
|
||||
if "kneeling in front of a standing partner" in context:
|
||||
return f"{woman} kneels in front of {man} at hip height while {man} stands over her for visible ejaculation."
|
||||
if "standing with cum on the body" in context:
|
||||
return f"{woman} stands braced in front of {man} while he stays close at hip level and ejaculates semen across her body."
|
||||
if "squatting on top of a partner" in context:
|
||||
return f"{woman} squats over {man}'s hips while {man} lies on his back under her and ejaculates semen onto her body."
|
||||
if "reverse cowgirl over a partner's hips" in context:
|
||||
return f"{woman} straddles {man}'s hips facing away while {man} lies on his back under her and ejaculates semen onto her body."
|
||||
if any(term in context for term in ("straddling a partner", "straddling a partner's hips", "shared climax after penetration", "orgasm during penetration")):
|
||||
return f"{woman} straddles {man}'s hips while {man} lies on his back under her, their bodies still aligned from penetration as he ejaculates semen onto her body."
|
||||
if "seated in a partner's lap facing them" in context:
|
||||
return f"{woman} sits in {man}'s lap facing him, legs wrapped around his hips as he ejaculates semen across her body."
|
||||
if any(term in context for term in ("lower back", "cum dripping from ass", "cum on lower back")) or _mentions_ass(context):
|
||||
return f"{woman} is bent forward with hips raised while {man} is positioned behind her, visible semen across her lower back, ass, and thighs."
|
||||
if any(term in context for term in ("cum on face", "cum on tongue", "cum on lips", "cum on face and lips", "cum on tongue and chin")):
|
||||
if third:
|
||||
return f"{woman} kneels in the center while {man} and {third} stand close around her face and torso for visible ejaculation."
|
||||
return f"{woman} kneels in front of {man} at hip height while {man} ejaculates semen onto her face, lips, and chest."
|
||||
return f"{woman} lies on her back with thighs open while {man} kneels between her legs and ejaculates semen onto her body."
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .hardcore_role_interaction import build_group_coordination_role_graph, build_manual_role_graph
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from hardcore_role_interaction import build_group_coordination_role_graph, build_manual_role_graph
|
||||
|
||||
|
||||
def build_support_sentence(rng: random.Random, people: list[str], exclude: set[str]) -> str:
|
||||
extras = [person for person in people if person not in exclude]
|
||||
if not extras:
|
||||
return ""
|
||||
extra = rng.choice(extras)
|
||||
actions = [
|
||||
"kisses and grips the nearest body",
|
||||
"holds hips open for the camera",
|
||||
"touches breasts, thighs, and stomach",
|
||||
"keeps one hand on a partner's ass",
|
||||
"watches close and joins the body contact",
|
||||
"presses in from the side with hands on skin",
|
||||
]
|
||||
return f" {extra} {rng.choice(actions)}."
|
||||
|
||||
|
||||
def build_solo_role_graph(
|
||||
solo: str,
|
||||
women_count: int,
|
||||
slug: str,
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if women_count == 1:
|
||||
if "manual_stimulation" in slug:
|
||||
return build_manual_role_graph(solo, item_text=item_text, item_axis_values=item_axis_values)
|
||||
if "camera_performance" in slug:
|
||||
return f"{solo} faces the camera and presents her body with hands framing the exposed skin in a solo creator-shot pose."
|
||||
if "cumshot" in slug or "climax" in slug:
|
||||
return f"{solo} is shown in a solo explicit orgasm pose with thighs open, one hand on her body, and visible arousal on skin and sheets."
|
||||
return f"{solo} is shown in a solo explicit adult pose with self-touch, open body framing, and direct camera awareness."
|
||||
if "cumshot" in slug or "climax" in slug:
|
||||
return f"{solo} is shown in a solo visible ejaculation pose with one hand on his penis, body angled toward the camera, and semen visible."
|
||||
return f"{solo} is shown in a solo explicit adult pose with direct camera awareness and clear body framing."
|
||||
|
||||
|
||||
def build_women_only_role_graph(
|
||||
slug: str,
|
||||
a: str,
|
||||
b: str,
|
||||
c: str = "",
|
||||
fallback_helper: str = "",
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> tuple[str, set[str]]:
|
||||
used = {a, b}
|
||||
if "manual_stimulation" in slug:
|
||||
return build_manual_role_graph(a, b, item_text, item_axis_values), used
|
||||
if "group_coordination" in slug and c:
|
||||
used.add(c)
|
||||
return build_group_coordination_role_graph(a, b, c, item_text=item_text, item_axis_values=item_axis_values), used
|
||||
if "outercourse" in slug:
|
||||
return f"{a} kneels close to {b}'s body and uses mouth, hands, breasts, or feet for explicit non-penetrative contact.", used
|
||||
if "oral" in slug:
|
||||
return f"{a} kneels between {b}'s spread thighs and uses tongue and fingers on her pussy.", used
|
||||
if "anal" in slug or "double" in slug:
|
||||
return f"{a} uses a strap-on on {b} while keeping her hips held open.", used
|
||||
if "threesome" in slug or "group" in slug or "orgy" in slug:
|
||||
helper = c or fallback_helper or b
|
||||
used.add(helper)
|
||||
return f"{a} uses a strap-on on {b} while {helper} gives oral contact and touches both bodies.", used
|
||||
if "cumshot" in slug or "climax" in slug:
|
||||
return f"{a} brings {b} to orgasm with mouth and fingers while wetness is visible on thighs and sheets.", used
|
||||
return f"{a} uses a strap-on on {b} while their bodies stay pressed together.", used
|
||||
|
||||
|
||||
def build_men_only_role_graph(
|
||||
slug: str,
|
||||
a: str,
|
||||
b: str,
|
||||
c: str = "",
|
||||
fallback_helper: str = "",
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> tuple[str, set[str]]:
|
||||
used = {a, b}
|
||||
if "manual_stimulation" in slug:
|
||||
return f"{a} and {b} sit or recline close together with hands visibly stimulating bodies in a manual sex setup.", used
|
||||
if "group_coordination" in slug and c:
|
||||
used.add(c)
|
||||
return build_group_coordination_role_graph(a, b, c, item_text=item_text, item_axis_values=item_axis_values), used
|
||||
if any(token in slug for token in ("foreplay", "body_worship", "clothing_position", "dominant_guidance", "camera_performance", "aftercare")):
|
||||
return f"{a} and {b} press close together, kissing and caressing skin while clothing is pulled aside.", used
|
||||
if "outercourse" in slug:
|
||||
return f"{a} and {b} keep explicit non-penetrative penis contact visible with hands, mouth, or feet.", used
|
||||
if "oral" in slug:
|
||||
return f"{a} kneels and takes {b}'s penis in his mouth while holding his hips.", used
|
||||
if "anal" in slug or "double" in slug or "penetrative" in slug:
|
||||
return f"{a} penetrates {b} anally while {b}'s hips are held open.", used
|
||||
if "threesome" in slug or "group" in slug or "orgy" in slug:
|
||||
helper = c or fallback_helper or b
|
||||
used.add(helper)
|
||||
return f"{a} penetrates {b} anally while {helper} gives oral contact from the front.", used
|
||||
if "cumshot" in slug or "climax" in slug:
|
||||
return f"{a} ejaculates semen over {b}'s body while {b} keeps eye contact and one hand on his penis.", used
|
||||
return f"{a} and {b} keep explicit penis and anal contact visible.", used
|
||||
|
||||
|
||||
def build_mixed_group_fallback_role_graph(
|
||||
woman: str,
|
||||
man: str,
|
||||
third: str,
|
||||
helper: str,
|
||||
slug: str,
|
||||
) -> str:
|
||||
if "threesome" in slug:
|
||||
return f"{man} thrusts his penis into {woman} while {third or helper} uses mouth and hands on the exposed body."
|
||||
if "group" in slug or "orgy" in slug:
|
||||
return f"{man} thrusts his penis into {woman} while surrounding partners give oral contact and keep hands on hips, breasts, and thighs."
|
||||
return ""
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
from .hardcore_role_anal import build_anal_or_double_role_graph
|
||||
from .hardcore_role_climax import build_climax_role_graph
|
||||
from .hardcore_role_fallback import (
|
||||
build_men_only_role_graph,
|
||||
build_mixed_group_fallback_role_graph,
|
||||
build_solo_role_graph,
|
||||
build_support_sentence,
|
||||
build_women_only_role_graph,
|
||||
)
|
||||
from .hardcore_role_interaction import (
|
||||
build_foreplay_role_graph,
|
||||
build_group_coordination_role_graph,
|
||||
build_interaction_role_graph,
|
||||
build_manual_role_graph,
|
||||
)
|
||||
from .hardcore_role_oral import build_oral_role_graph
|
||||
from .hardcore_role_outercourse import build_outercourse_role_graph
|
||||
from .hardcore_role_penetration import build_penetration_role_graph
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
import item_axis_policy
|
||||
from hardcore_role_anal import build_anal_or_double_role_graph
|
||||
from hardcore_role_climax import build_climax_role_graph
|
||||
from hardcore_role_fallback import (
|
||||
build_men_only_role_graph,
|
||||
build_mixed_group_fallback_role_graph,
|
||||
build_solo_role_graph,
|
||||
build_support_sentence,
|
||||
build_women_only_role_graph,
|
||||
)
|
||||
from hardcore_role_interaction import (
|
||||
build_foreplay_role_graph,
|
||||
build_group_coordination_role_graph,
|
||||
build_interaction_role_graph,
|
||||
build_manual_role_graph,
|
||||
)
|
||||
from hardcore_role_oral import build_oral_role_graph
|
||||
from hardcore_role_outercourse import build_outercourse_role_graph
|
||||
from hardcore_role_penetration import build_penetration_role_graph
|
||||
|
||||
|
||||
def _lettered(prefix: str, count: int) -> list[str]:
|
||||
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
return [f"{prefix.capitalize()} {letters[index]}" for index in range(max(0, count))]
|
||||
|
||||
|
||||
def _pick_distinct(rng: random.Random, items: list[str], count: int) -> list[str]:
|
||||
if not items:
|
||||
return []
|
||||
if len(items) >= count:
|
||||
return rng.sample(items, count)
|
||||
picked = list(items)
|
||||
while len(picked) < count:
|
||||
picked.append(items[rng.randrange(len(items))])
|
||||
return picked
|
||||
|
||||
|
||||
def _participant_context(women_count: int, men_count: int) -> dict[str, list[str]]:
|
||||
women = _lettered("woman", women_count)
|
||||
men = _lettered("man", men_count)
|
||||
return {"women": women, "men": men, "people": women + men}
|
||||
|
||||
|
||||
def build_hardcore_role_graph(
|
||||
rng: random.Random,
|
||||
subcategory: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
pov_labels: list[str] | None = None,
|
||||
) -> str:
|
||||
if context.get("subject_type") != "configured_cast":
|
||||
return ""
|
||||
women_count = int(context.get("women_count") or 0)
|
||||
men_count = int(context.get("men_count") or 0)
|
||||
people_count = women_count + men_count
|
||||
if people_count <= 0:
|
||||
return ""
|
||||
|
||||
participants = _participant_context(women_count, men_count)
|
||||
women = participants["women"]
|
||||
men = participants["men"]
|
||||
people = participants["people"]
|
||||
slug = str(subcategory.get("slug") or subcategory.get("name") or "").lower()
|
||||
item_text = item_axis_policy.context_text(axis_values=item_axis_values)
|
||||
|
||||
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)
|
||||
|
||||
if people_count == 1:
|
||||
return build_solo_role_graph(people[0], women_count, slug, item_text, item_axis_values)
|
||||
|
||||
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 any(token in slug for token in ("foreplay", "body_worship", "clothing_position", "dominant_guidance", "camera_performance", "aftercare")):
|
||||
graph = build_interaction_role_graph(a, b, c, slug, item_text, item_axis_values)
|
||||
if c and "camera_performance" in slug:
|
||||
used.add(c)
|
||||
elif "foreplay" in slug:
|
||||
graph = build_foreplay_role_graph(a, b, item_text, item_axis_values)
|
||||
else:
|
||||
graph, used = build_women_only_role_graph(
|
||||
slug,
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
c or any_woman({a}),
|
||||
item_text,
|
||||
item_axis_values,
|
||||
)
|
||||
return graph + build_support_sentence(rng, people, 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 ""
|
||||
graph, used = build_men_only_role_graph(
|
||||
slug,
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
c or any_man({a}),
|
||||
item_text,
|
||||
item_axis_values,
|
||||
)
|
||||
return graph + build_support_sentence(rng, people, used)
|
||||
|
||||
woman = any_woman()
|
||||
man = any_man()
|
||||
third = any_person({woman, man}) if people_count >= 3 else ""
|
||||
if "manual_stimulation" in slug:
|
||||
graph = build_manual_role_graph(woman, man, item_text, item_axis_values)
|
||||
elif "group_coordination" in slug:
|
||||
graph = build_group_coordination_role_graph(
|
||||
woman,
|
||||
man,
|
||||
third,
|
||||
any_person({woman, man}) if not third else "",
|
||||
item_text,
|
||||
item_axis_values,
|
||||
)
|
||||
elif any(token in slug for token in ("foreplay", "body_worship", "clothing_position", "dominant_guidance", "camera_performance", "aftercare")):
|
||||
graph = build_interaction_role_graph(woman, man, third, slug, item_text, item_axis_values)
|
||||
elif "foreplay" in slug:
|
||||
graph = build_foreplay_role_graph(woman, man, item_text, item_axis_values)
|
||||
elif "outercourse" in slug:
|
||||
graph = build_outercourse_role_graph(woman, man, item_text, item_axis_values, pov_labels)
|
||||
elif "oral" in slug:
|
||||
graph = build_oral_role_graph(woman, man, item_text, item_axis_values, pov_labels)
|
||||
elif "anal" in slug or "double" in slug:
|
||||
graph = build_anal_or_double_role_graph(woman, man, third, people_count, item_text, item_axis_values)
|
||||
elif "threesome" in slug or "group" in slug or "orgy" in slug:
|
||||
graph = build_mixed_group_fallback_role_graph(woman, man, third, any_person({woman, man}), slug)
|
||||
elif "cumshot" in slug or "climax" in slug:
|
||||
graph = build_climax_role_graph(woman, man, third, item_text, item_axis_values)
|
||||
else:
|
||||
graph = build_penetration_role_graph(woman, man, item_text, item_axis_values)
|
||||
return graph + build_support_sentence(rng, people, {woman, man, third} if third else {woman, man})
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str:
|
||||
return item_axis_policy.context_text(item_text, axis_values=item_axis_values)
|
||||
|
||||
|
||||
def build_foreplay_role_graph(
|
||||
primary: str,
|
||||
partner: str,
|
||||
item_text: str,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
if any(term in text for term in ("undressing", "removing clothing", "removing clothes", "pulling clothing", "sliding straps", "unbuttoning")):
|
||||
return (
|
||||
f"{primary} and {partner} stand close while {partner}'s hands pull clothing aside from {primary}'s body; "
|
||||
f"{primary}'s exposed skin and the clothing being removed stay clearly visible."
|
||||
)
|
||||
if any(term in text for term in ("breast", "breasts", "nipple", "cupping breasts", "touching breasts")):
|
||||
return (
|
||||
f"{primary} and {partner} press their bodies close while {partner}'s hand cups {primary}'s breast; "
|
||||
f"their faces stay close and the breast-touching gesture is clear."
|
||||
)
|
||||
if any(term in text for term in ("face", "cheek", "jaw", "chin", "hand on the cheek", "fingers under the chin")):
|
||||
return (
|
||||
f"{primary} and {partner} stand face-to-face at close range while one hand holds {primary}'s cheek and jaw; "
|
||||
f"their lips are close and the face-touching gesture is clear."
|
||||
)
|
||||
if any(term in text for term in ("kiss", "kissing", "mouth-to-mouth", "lips pressed")):
|
||||
return (
|
||||
f"{primary} and {partner} press their bodies together and kiss deeply, "
|
||||
f"with hands on each other's face, waist, and hips."
|
||||
)
|
||||
return (
|
||||
f"{primary} and {partner} are pressed close in a heated foreplay setup, "
|
||||
f"hands caressing skin while clothing is pulled aside."
|
||||
)
|
||||
|
||||
|
||||
def build_manual_role_graph(
|
||||
primary: str,
|
||||
partner: str = "",
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
if not partner:
|
||||
if "mutual" in text:
|
||||
return f"{primary} faces the camera with thighs open, both hands on her body for solo mutual-style masturbation framing."
|
||||
return f"{primary} reclines with thighs open, one hand between her legs and fingers visibly stimulating her pussy."
|
||||
if "mutual" in text:
|
||||
return f"{primary} and {partner} sit close facing each other, both touching themselves while keeping hands, faces, and bodies visible."
|
||||
if "clit" in text or "clitoris" in text:
|
||||
return f"{primary} reclines with thighs open while {partner}'s hand is between her legs, fingers rubbing her clit as her hips tilt toward the touch."
|
||||
if "toy" in text or "vibrator" in text:
|
||||
return f"{primary} reclines with thighs open while {partner} holds a vibrator or toy against her clit, one hand keeping her thigh open."
|
||||
return f"{primary} reclines with thighs open while {partner}'s hand is between her legs, fingers visibly stimulating her pussy."
|
||||
|
||||
|
||||
def build_interaction_role_graph(
|
||||
primary: str,
|
||||
partner: str,
|
||||
third: str = "",
|
||||
slug: str = "",
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
if "aftercare" in slug or any(term in text for term in ("aftercare", "cleanup", "wiping", "towel", "post-sex", "cuddle")):
|
||||
if "cleanup" in text or "wiping" in text or "towel" in text:
|
||||
return f"{primary} reclines after sex while {partner} kneels close and wipes her skin with a towel, hands and relaxed body contact visible."
|
||||
return f"{primary} and {partner} lie close together after sex, bodies relaxed and hands resting on skin in a post-sex cuddle."
|
||||
if "camera_performance" in slug or any(term in text for term in ("camera", "presenting", "showing", "viewer", "creator-shot")):
|
||||
if third:
|
||||
return f"{primary} faces the camera while {partner} and {third} hold and present her body, hands framing the exposed skin for the viewer."
|
||||
return f"{primary} faces the camera and presents her body while {partner}'s hands hold her hips or thighs open for a clear creator-shot reveal."
|
||||
if "body_worship" in slug or any(term in text for term in ("body worship", "nipple", "thigh", "mouth on skin", "kissing down", "ass grabbing")):
|
||||
if "ass" in text:
|
||||
return f"{primary} stands or kneels with hips angled back while {partner}'s hands grip her ass, fingers pressing into skin."
|
||||
if "thigh" in text:
|
||||
return f"{primary} reclines with thighs open while {partner} kneels close and kisses along her inner thighs, hands holding her legs in place."
|
||||
if "nipple" in text or "breast" in text:
|
||||
return f"{primary} arches toward {partner} while {partner}'s mouth is on her breast and one hand cups or squeezes the other breast."
|
||||
return f"{primary} reclines or leans back while {partner} kisses down her body, hands tracing breasts, waist, hips, and thighs."
|
||||
if "clothing_position" in slug or any(term in text for term in ("transition", "turning", "pulling onto", "lifting", "guided backward", "clothing", "garment")):
|
||||
if "turn" in text or "rear-facing" in text:
|
||||
return f"{partner}'s hands turn {primary} around by the hips, clothing partly moved aside as her body rotates into the next pose."
|
||||
if "legs" in text or "thigh" in text:
|
||||
return f"{primary} lies back while {partner} lifts and spreads her legs into position, hands and clothing movement clearly visible."
|
||||
return f"{primary} and {partner} are mid-transition, with {partner}'s hands moving clothing aside and guiding {primary}'s hips toward the next pose."
|
||||
if "dominant" in slug or any(term in text for term in ("hair", "wrist", "wrists", "jaw", "chin", "guided", "dominant", "control", "dirty talk", "whisper", "mouth near the ear", "verbal teasing")):
|
||||
if "dirty talk" in text or "whisper" in text or "mouth near the ear" in text or "verbal teasing" in text:
|
||||
return f"{partner} leans close to {primary}'s ear for dirty talk while holding her waist and keeping their bodies pressed close."
|
||||
if "wrist" in text or "wrists" in text:
|
||||
return f"{primary} lies back while {partner} pins her wrists above her head, both bodies close and the consensual control gesture clearly visible."
|
||||
if "hair" in text:
|
||||
return f"{partner} holds {primary}'s hair back while guiding her body closer, face and hair-hold gesture visible."
|
||||
if "thigh" in text or "spread" in text:
|
||||
return f"{primary} reclines with thighs open while {partner}'s hands spread her legs and hold the position for the camera."
|
||||
return f"{partner} guides {primary}'s body with hands on her jaw, waist, and hips, keeping the consensual control gesture readable."
|
||||
return build_foreplay_role_graph(primary, partner, item_text, item_axis_values)
|
||||
|
||||
|
||||
def build_group_coordination_role_graph(
|
||||
primary: str,
|
||||
partner: str,
|
||||
third: str = "",
|
||||
fallback_observer: str = "",
|
||||
item_text: str = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
observer = third or fallback_observer or partner
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
if "camera" in text or "hold" in text or "present" in text:
|
||||
return f"{primary} is centered while {partner} and {observer} hold and present the body for the camera, each role clearly visible."
|
||||
if "watch" in text or "waiting" in text:
|
||||
return f"{primary} is centered while {partner} touches her body and {observer} watches close beside them, hands and faces readable."
|
||||
return f"{primary} is centered while {partner} touches her body and {observer} stays close as the watching or guiding partner."
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str:
|
||||
return item_axis_policy.context_text(item_text, axis_values=item_axis_values)
|
||||
|
||||
|
||||
def _oral_direction(text: str) -> tuple[bool, bool]:
|
||||
woman_gives = any(
|
||||
term in text
|
||||
for term in (
|
||||
"fellatio",
|
||||
"blowjob",
|
||||
"deepthroat",
|
||||
"penis sucking",
|
||||
"penis in mouth",
|
||||
"penis in her mouth",
|
||||
"mouth stretched around a penis",
|
||||
"lips wrapped",
|
||||
)
|
||||
)
|
||||
man_gives = any(
|
||||
term in text
|
||||
for term in (
|
||||
"cunnilingus",
|
||||
"pussy licking",
|
||||
"tongue on pussy",
|
||||
"mouth on pussy",
|
||||
"pussy and tongue",
|
||||
"face-sitting",
|
||||
"tongue contact clearly visible",
|
||||
)
|
||||
)
|
||||
if "mouth on genitals" in text and not woman_gives and not man_gives:
|
||||
if any(term in text for term in ("face-sitting", "reclining", "straddled", "spread-leg", "open thighs")):
|
||||
man_gives = True
|
||||
else:
|
||||
woman_gives = True
|
||||
return woman_gives, man_gives
|
||||
|
||||
|
||||
def build_oral_role_graph(
|
||||
woman: str,
|
||||
man: str,
|
||||
item_text: str,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
pov_labels: list[str] | None = None,
|
||||
) -> str:
|
||||
position_text = item_axis_policy.key_text(item_axis_values, "position")
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
man_is_pov = man in set(pov_labels or [])
|
||||
woman_gives, man_gives = _oral_direction(text)
|
||||
|
||||
if "sixty-nine" in position_text or ("sixty-nine" in text and not position_text):
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} and the viewer lie head-to-hips in a sixty-nine position, "
|
||||
f"with {woman}'s mouth on the viewer's penis and the viewer's mouth on {woman}'s pussy."
|
||||
)
|
||||
return f"{woman} and {man} lie head-to-hips in a sixty-nine position, with {woman}'s mouth on {man}'s penis and {man}'s mouth on {woman}'s pussy."
|
||||
if "face-sitting" in position_text or ("face-sitting" in text and not position_text):
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} is above the POV camera, straddling the viewer's face with thighs on both sides of his head, "
|
||||
"pussy directly over the viewer's mouth for close first-person underview tongue contact."
|
||||
)
|
||||
return f"{man} lies on his back while {woman} straddles his face with her thighs around his head and {man}'s mouth pressed to her pussy."
|
||||
if "straddled oral" in position_text or ("straddled oral" in text and not position_text):
|
||||
if woman_gives and not man_gives:
|
||||
if man_is_pov:
|
||||
return f"The viewer straddles forward near {woman}'s face while {woman} kneels below him with her mouth on his penis."
|
||||
return f"{man} straddles forward near {woman}'s face while {woman} kneels below him with her mouth on his penis."
|
||||
if man_is_pov:
|
||||
return f"{woman} straddles above the viewer's face with her thighs framing his head while the viewer's mouth stays pressed to her pussy."
|
||||
return f"{woman} straddles above {man}'s face with her thighs framing his head while {man}'s mouth stays pressed to her pussy."
|
||||
if "side-lying oral" in position_text or ("side-lying oral" in text and not position_text):
|
||||
if woman_gives and not man_gives:
|
||||
if man_is_pov:
|
||||
return f"The viewer lies on his side with hips angled toward {woman} while {woman} lies beside his thighs and takes the viewer's penis in her mouth."
|
||||
return f"{man} lies on his side with hips angled toward {woman} while {woman} lies beside his thighs and takes his penis in her mouth."
|
||||
if man_is_pov:
|
||||
return f"{woman} lies on her side with her top thigh lifted while the viewer lies beside her hips with his mouth pressed to her pussy."
|
||||
return f"{woman} lies on her side with her top thigh lifted while {man} lies beside her hips with his mouth pressed to her pussy."
|
||||
if (
|
||||
"edge-of-bed oral" in position_text
|
||||
or "edge of bed oral" in position_text
|
||||
or "edge-supported oral" in position_text
|
||||
or (("edge-of-bed oral" in text or "edge of bed oral" in text or "edge-supported oral" in text) and not position_text)
|
||||
):
|
||||
if woman_gives and not man_gives:
|
||||
if man_is_pov:
|
||||
return f"The viewer sits at a raised edge with legs apart while {woman} kneels between his thighs and takes the viewer's penis in her mouth."
|
||||
return f"{man} sits at a raised edge with legs apart while {woman} kneels between his thighs and takes his penis in her mouth."
|
||||
if man_is_pov:
|
||||
return f"{woman} lies at a raised edge with thighs open while the viewer kneels between her legs with his mouth on her pussy."
|
||||
return f"{woman} lies at a raised edge with thighs open while {man} kneels between her legs with his mouth on her pussy."
|
||||
if "standing oral" in position_text or ("standing oral" in text and not position_text):
|
||||
if man_gives and not woman_gives:
|
||||
if man_is_pov:
|
||||
return f"{woman} stands braced with one thigh lifted while the viewer kneels between her legs with his mouth on her pussy."
|
||||
return f"{woman} stands braced with one thigh lifted while {man} kneels between her legs with his mouth on her pussy."
|
||||
if man_is_pov:
|
||||
return f"The viewer stands with hips forward while {woman} kneels in front of him at hip height and takes the viewer's penis in her mouth."
|
||||
return f"{man} stands with hips forward while {woman} kneels in front of him at hip height and takes his penis in her mouth."
|
||||
if "chair oral" in position_text or ("chair oral" in text and not position_text):
|
||||
if man_gives and not woman_gives:
|
||||
if man_is_pov:
|
||||
return f"{woman} sits in a chair with thighs open while the viewer kneels between her legs with his mouth pressed to her pussy."
|
||||
return f"{woman} sits in a chair with thighs open while {man} kneels between her legs with his mouth pressed to her pussy."
|
||||
if man_is_pov:
|
||||
return f"The viewer sits in a chair with legs apart while {woman} kneels between his thighs and takes the viewer's penis in her mouth."
|
||||
return f"{man} sits in a chair with legs apart while {woman} kneels between his thighs and takes his penis in her mouth."
|
||||
if (
|
||||
"reclining cunnilingus" in position_text
|
||||
or "spread-leg oral" in position_text
|
||||
or (("reclining cunnilingus" in text or "spread-leg oral" in text) and not position_text)
|
||||
):
|
||||
if woman_gives and not man_gives:
|
||||
if man_is_pov:
|
||||
return f"The viewer reclines with legs apart while {woman} kneels between his thighs and takes the viewer's penis in her mouth."
|
||||
return f"{man} reclines with legs apart while {woman} kneels between his thighs and takes his penis in her mouth."
|
||||
if man_is_pov:
|
||||
return f"{woman} reclines on her back with thighs spread while the viewer kneels between her legs with his mouth on her pussy."
|
||||
return f"{woman} reclines on her back with thighs spread while {man} kneels between her legs with his mouth on her pussy."
|
||||
if "kneeling oral" in position_text or ("kneeling oral" in text and not position_text):
|
||||
if man_gives and not woman_gives:
|
||||
if man_is_pov:
|
||||
return f"{woman} kneels with thighs parted and hips angled forward while the viewer kneels in front of her with his mouth on her pussy."
|
||||
return f"{woman} kneels with thighs parted and hips angled forward while {man} kneels in front of her with his mouth on her pussy."
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} kneels in front of the viewer's penis while he stands over her; "
|
||||
f"{woman} takes the viewer's penis in her mouth with saliva dripping on the penis as he looks down toward her."
|
||||
)
|
||||
return (
|
||||
f"{woman} kneels in front of {man}'s penis while {man} stands over her; "
|
||||
f"{woman} takes {man}'s penis in her mouth with saliva dripping on the penis as {man} looks down toward her."
|
||||
)
|
||||
if man_gives and not woman_gives:
|
||||
if man_is_pov:
|
||||
return f"{woman} lies on her back with thighs open while the viewer kneels between her legs with his mouth pressed to her pussy."
|
||||
return f"{woman} lies on her back with thighs open while {man} kneels between her legs with his mouth pressed to her pussy."
|
||||
if man_is_pov:
|
||||
return f"{woman} kneels in front of the viewer's hips and takes the viewer's penis in her mouth while he keeps his hips aligned with her face."
|
||||
return f"{woman} kneels in front of {man}'s hips and takes his penis in her mouth while {man} keeps his hips aligned with her face."
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
from . import outercourse_action_policy as outercourse_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
import outercourse_action_policy as outercourse_policy
|
||||
|
||||
|
||||
def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str:
|
||||
return item_axis_policy.context_text(item_text, axis_values=item_axis_values)
|
||||
|
||||
|
||||
def build_outercourse_role_graph(
|
||||
woman: str,
|
||||
man: str,
|
||||
item_text: str,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
pov_labels: list[str] | None = None,
|
||||
) -> str:
|
||||
position_text = item_axis_policy.key_text(item_axis_values, "position")
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(position_text)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_GENERIC:
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(text)
|
||||
man_is_pov = man in set(pov_labels or [])
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB:
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} kneels low between the POV viewer's open thighs with her torso bent forward over his pelvis, "
|
||||
"both hands pushing her breasts inward around the POV viewer's penis, the penis held between her breasts in the lower foreground, "
|
||||
"her chin and lips directly above the glans at the tip."
|
||||
)
|
||||
return (
|
||||
f"{man} sits with legs apart while {woman} kneels low between his open thighs with her torso bent forward over his pelvis, "
|
||||
f"{woman}'s hands pushing her breasts inward around {man}'s penis, the penis held between her breasts, "
|
||||
"her chin and lips directly above the glans at the tip."
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE:
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} bends forward and kneels very low between the POV viewer's open thighs with her shoulders between his knees, "
|
||||
"her face below the POV viewer's penis at testicle height, mouth and tongue on the POV viewer's balls, "
|
||||
"while his penis points upward in the lower foreground above her forehead."
|
||||
)
|
||||
return (
|
||||
f"{man} sits with legs apart while {woman} kneels very low between his open thighs with her torso bent forward and shoulders between his knees, "
|
||||
f"{woman}'s face below {man}'s penis at testicle height, mouth and tongue on his balls, while {man}'s penis points upward above her forehead."
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING:
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} bends forward between the POV viewer's open thighs with her head low under the POV viewer's penis, "
|
||||
"her face just under the penis while her tongue touches the underside from the base toward the glans at the tip, "
|
||||
"one hand steadying the base of the POV viewer's penis."
|
||||
)
|
||||
return (
|
||||
f"{woman} bends forward between {man}'s open thighs with her head low under {man}'s penis, "
|
||||
f"her face just under the penis while her tongue touches the underside from the base toward the glans at the tip, "
|
||||
f"one hand steadying the base of {man}'s penis."
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB:
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} faces the POV viewer with her hips back, torso visible behind her raised legs, and both knees bent open toward the camera, "
|
||||
"both soles wrapped around the POV viewer's penis shaft in the lower foreground."
|
||||
)
|
||||
return (
|
||||
f"{man} reclines with hips forward while {woman} faces him with her hips back and both knees bent open, "
|
||||
f"wrapping both soles around {man}'s penis shaft while the contact stays centered."
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB:
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} kneels between the POV viewer's open thighs with her torso leaning forward and face visible behind the POV viewer's penis, "
|
||||
"one hand grips and strokes the POV viewer's penis in the lower foreground while the other hand steadies its base, "
|
||||
"thumb and fingers visible around the penis as she strokes toward the glans."
|
||||
)
|
||||
return (
|
||||
f"{woman} kneels between {man}'s open thighs with her torso leaning forward and face visible behind {man}'s penis, "
|
||||
f"one hand grips and strokes {man}'s penis while the other hand steadies its base, "
|
||||
"thumb and fingers visible around the penis as she strokes toward the glans."
|
||||
)
|
||||
if man_is_pov:
|
||||
return (
|
||||
f"{woman} kneels close to the POV viewer's hips and keeps the POV viewer's penis centered in clear non-penetrative contact, "
|
||||
"with her mouth, hands, breasts, or feet visibly working around the penis shaft."
|
||||
)
|
||||
return (
|
||||
f"{woman} kneels close to {man}'s hips and keeps {man}'s penis centered in clear non-penetrative contact, "
|
||||
"with her mouth, hands, breasts, or feet visibly working around the penis shaft."
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
def _context_text(item_text: str, item_axis_values: dict[str, Any] | None) -> str:
|
||||
return item_axis_policy.context_text(item_text, axis_values=item_axis_values)
|
||||
|
||||
|
||||
def build_penetration_role_graph(
|
||||
woman: str,
|
||||
man: str,
|
||||
item_text: str,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
text = _context_text(item_text, item_axis_values)
|
||||
if "missionary" in text:
|
||||
return (
|
||||
f"{woman} lies on her back with legs open around {man}'s hips while {man} is above her between her thighs; "
|
||||
f"{man}'s hips press close and {man}'s penis thrusts into her pussy."
|
||||
)
|
||||
if "reverse cowgirl" in text:
|
||||
return f"{woman} straddles {man}'s hips facing away while {man} lies under her and {man}'s penis thrusts into her pussy."
|
||||
if "cowgirl" in text or "straddling" in text:
|
||||
return f"{woman} straddles {man}'s hips facing him while {man} lies under her and {man}'s penis thrusts into her pussy."
|
||||
if "doggy" in text or "rear-entry" in text or "bent-over" in text or "bent over" in text:
|
||||
return f"{woman} is on all fours with hips raised while {man} is positioned behind her and {man}'s penis thrusts into her pussy."
|
||||
if "standing" in text:
|
||||
return f"{woman} stands braced with hips angled back while {man} stands behind her and {man}'s penis thrusts into her pussy."
|
||||
if "spooning" in text or "side-lying" in text:
|
||||
return f"{woman} lies on her side with thighs parted while {man} presses behind her and {man}'s penis thrusts into her pussy."
|
||||
if "edge-of-bed" in text or "edge of bed" in text or "bed edge" in text or "edge-supported" in text or "raised edge" in text:
|
||||
return (
|
||||
f"{woman} lies back at a raised edge with hips at the edge and legs open while {man} kneels between her thighs; "
|
||||
f"{man}'s hips press close and {man}'s penis thrusts into her pussy."
|
||||
)
|
||||
if "kneeling straddle" in text:
|
||||
return f"{woman} kneels straddling {man}'s hips while {man} supports her waist and {man}'s penis thrusts into her pussy."
|
||||
if "lotus" in text:
|
||||
return f"{woman} sits in {man}'s lap facing him with legs around his hips while {man}'s penis thrusts into her pussy."
|
||||
return (
|
||||
f"{woman} lies on her back with legs spread wide and knees bent outward while {man} kneels between her open thighs facing her; "
|
||||
f"{man}'s hips are pressed between her legs and {man}'s penis thrusts into her pussy."
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS = (
|
||||
(r"\bon against a wall\b", "against a wall"),
|
||||
(r"\bstacked bodies on the bed\b", "close body alignment"),
|
||||
(r"\bstacked bodies with close body alignment\b", "close body alignment"),
|
||||
(r"\boverhead tangled-body anal frame\b", "overhead rear-entry anal frame"),
|
||||
(r"\btangled-body\b", "close-body"),
|
||||
(r"\bthree bodies tangled on the bed\b", "three bodies tangled in close contact"),
|
||||
(r"\ba triangle of bodies on the mattress\b", "a triangle of bodies in close contact"),
|
||||
(r"\bbodies tangled on the sheets\b", "bodies tangled in close contact"),
|
||||
(r"\bwet bodies tangled on sheets\b", "wet bodies tangled in close contact"),
|
||||
(r"\bbody arched on rumpled sheets\b", "body arched with clear skin contact"),
|
||||
(r"\bface-down ass-up on the mattress\b", "face-down ass-up position"),
|
||||
(r"\bsitting on the edge of the bed\b", "sitting on a raised edge"),
|
||||
(r"\blying at the bed edge with thighs open\b", "lying near a raised edge with thighs open"),
|
||||
(r"\bedge[- ]of[- ]bed\b", "edge-supported"),
|
||||
(r"\bbed[- ]edge\b", "raised edge"),
|
||||
(r"\bedge of (?:the )?bed\b", "raised edge"),
|
||||
(r"\bbed edge\b", "raised edge"),
|
||||
(r"\bhands? braced on the bed\b", "hands braced beside the body"),
|
||||
(r"\bone hand pressing into the mattress\b", "one hand braced beside the body"),
|
||||
(r"\bone foot planted on the bed\b", "one foot planted for leverage"),
|
||||
(r"\bfingers gripping sheets and skin\b", "fingers gripping skin"),
|
||||
(r"\bfingers gripping sheets\b", "fingers gripping skin"),
|
||||
(r"\bhands gripping sheets\b", "hands gripping skin"),
|
||||
(r"\bone hand gripping the sheets\b", "one hand gripping skin"),
|
||||
(r"\brumpled bed sheets\b", "wrinkled body-contact fabric"),
|
||||
(r"\bwet sheets beneath the bodies\b", "visible wetness beneath the bodies"),
|
||||
(r"\bsexual fluids on sheets\b", "sexual fluids visible on skin"),
|
||||
(r"\bcum dripping onto sheets\b", "cum visible on skin"),
|
||||
(r"\bfluid dripping onto sheets\b", "fluid visible on skin"),
|
||||
(r"\bsquirting fluid on the sheets\b", "squirting fluid visible on skin"),
|
||||
(r"\bsoft sheets\b", "soft fabric"),
|
||||
(r"\bsilk sheets\b", "silk fabric"),
|
||||
(r"\bsheets\b", "fabric"),
|
||||
(r"\bmattress\b", "low support surface"),
|
||||
(r"\ba low support surface\b", "a low body support"),
|
||||
(r"\ba low mattress\b", "a low body support"),
|
||||
(r"\ba wide couch\b", "a wide body support"),
|
||||
(r"\bwide couch\b", "wide body support"),
|
||||
(r"\bcouch\b", "body support"),
|
||||
(r"\bsofa\b", "body support"),
|
||||
(r"\bon the bed\b", "on a body support"),
|
||||
(r"\bon a bed\b", "on a body support"),
|
||||
(r"\bbedroom-floor\b", "floor-level"),
|
||||
(r"\bbedroom floor\b", "floor-level"),
|
||||
)
|
||||
|
||||
|
||||
def _clean_inline(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 sanitize_hardcore_environment_anchors(value: Any) -> str:
|
||||
text = _clean_inline(value)
|
||||
if not text:
|
||||
return ""
|
||||
for pattern, replacement in HARDCORE_ENVIRONMENT_ANCHOR_REPLACEMENTS:
|
||||
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\s+,", ",", text)
|
||||
text = re.sub(r",\s*,", ",", text)
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def sanitize_hardcore_axis_values(values: Any) -> dict[str, str]:
|
||||
if not isinstance(values, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): sanitize_hardcore_environment_anchors(value)
|
||||
for key, value in values.items()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_SWITCH_INPUTS = 64
|
||||
INDEX_SWITCH_MODES = ["pick_input", "route_output"]
|
||||
INDEX_SWITCH_BASES = ["one_based", "zero_based"]
|
||||
INDEX_SWITCH_MISSING_BEHAVIORS = ["fallback", "none", "clamp", "wrap"]
|
||||
|
||||
|
||||
def normalize_index_base(value: Any) -> str:
|
||||
return value if value in INDEX_SWITCH_BASES else "one_based"
|
||||
|
||||
|
||||
def normalize_missing_behavior(value: Any) -> str:
|
||||
return value if value in INDEX_SWITCH_MISSING_BEHAVIORS else "fallback"
|
||||
|
||||
|
||||
def normalize_mode(value: Any) -> str:
|
||||
return value if value in INDEX_SWITCH_MODES else "pick_input"
|
||||
|
||||
|
||||
def available_input_indices(kwargs: dict[str, Any]) -> list[int]:
|
||||
indices = []
|
||||
for key in kwargs:
|
||||
match = re.match(r"^input_(\d+)$", str(key))
|
||||
if match:
|
||||
indices.append(int(match.group(1)))
|
||||
return sorted(set(indices))
|
||||
|
||||
|
||||
def requested_index(index: Any, index_base: str) -> int:
|
||||
requested = int(index)
|
||||
return requested + 1 if normalize_index_base(index_base) == "zero_based" else requested
|
||||
|
||||
|
||||
def resolved_input_index(requested: int, available: list[int], missing_behavior: str) -> int | None:
|
||||
missing_behavior = normalize_missing_behavior(missing_behavior)
|
||||
if requested in available:
|
||||
return requested
|
||||
if missing_behavior in ("fallback", "none") or not available:
|
||||
return None
|
||||
if missing_behavior == "wrap":
|
||||
return available[(requested - 1) % len(available)]
|
||||
if requested <= available[0]:
|
||||
return available[0]
|
||||
if requested >= available[-1]:
|
||||
return available[-1]
|
||||
lower = [value for value in available if value <= requested]
|
||||
return lower[-1] if lower else available[0]
|
||||
|
||||
|
||||
def input_selection(index: Any, index_base: str, missing_behavior: str, kwargs: dict[str, Any]) -> tuple[int, int | None, list[int]]:
|
||||
requested = requested_index(index, index_base)
|
||||
available = available_input_indices(kwargs)
|
||||
selected = resolved_input_index(requested, available, missing_behavior)
|
||||
return requested, selected, available
|
||||
|
||||
|
||||
def route_selection(index: Any, index_base: str, missing_behavior: str, max_outputs: int = MAX_SWITCH_INPUTS) -> tuple[int, int | None]:
|
||||
requested = requested_index(index, index_base)
|
||||
max_outputs = max(1, int(max_outputs))
|
||||
missing_behavior = normalize_missing_behavior(missing_behavior)
|
||||
if 1 <= requested <= max_outputs:
|
||||
return requested, requested
|
||||
if missing_behavior == "wrap":
|
||||
return requested, ((requested - 1) % max_outputs) + 1
|
||||
if missing_behavior == "clamp":
|
||||
return requested, min(max(requested, 1), max_outputs)
|
||||
return requested, None
|
||||
|
||||
|
||||
def input_status(requested: int, selected: int | None, used_fallback: bool, available: list[int]) -> str:
|
||||
available_text = ",".join(str(index) for index in available) or "none"
|
||||
if used_fallback:
|
||||
return f"requested=input_{requested}; selected=fallback; available={available_text}"
|
||||
if selected is None:
|
||||
return f"requested=input_{requested}; selected=none; available={available_text}"
|
||||
return f"requested=input_{requested}; selected=input_{selected}; available={available_text}"
|
||||
|
||||
|
||||
def route_status(requested: int, selected: int | None, max_outputs: int = MAX_SWITCH_INPUTS) -> str:
|
||||
selected_text = "none" if selected is None else f"output_{selected}"
|
||||
return f"requested=output_{requested}; selected={selected_text}; range=1-{max_outputs}"
|
||||
|
||||
|
||||
def lazy_inputs(index: Any, mode: str, index_base: str, missing_behavior: str, kwargs: dict[str, Any]) -> list[str]:
|
||||
mode = normalize_mode(mode)
|
||||
missing_behavior = normalize_missing_behavior(missing_behavior)
|
||||
if mode == "route_output":
|
||||
return ["route_value"] if "route_value" in kwargs else []
|
||||
requested, selected, _available = input_selection(index, index_base, missing_behavior, kwargs)
|
||||
selected_name = f"input_{selected}" if selected is not None else f"input_{requested}"
|
||||
if selected_name in kwargs:
|
||||
return [selected_name]
|
||||
if missing_behavior == "fallback" and "fallback" in kwargs:
|
||||
return ["fallback"]
|
||||
return []
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
PLACEHOLDER_VALUES = {"", "any", "auto", "random", "none", "null"}
|
||||
PREFERRED_VALUE_KEYS = ("text", "prompt", "template", "value", "name")
|
||||
METADATA_AXIS_KEYS = {"action_family", "position_family", "position_key", "position_keys"}
|
||||
ACTION_CONTEXT_PRIORITY = (
|
||||
"position",
|
||||
"body_position",
|
||||
"body_arrangement",
|
||||
"arrangement",
|
||||
"angle",
|
||||
"surface",
|
||||
"body_contact",
|
||||
"leg_detail",
|
||||
"outer_act",
|
||||
"contact_detail",
|
||||
"texture_detail",
|
||||
"hand_detail",
|
||||
"visibility",
|
||||
"expression_detail",
|
||||
"oral_act",
|
||||
"oral_detail",
|
||||
"penetration_act",
|
||||
"penetration_detail",
|
||||
"anal_act",
|
||||
"double_act",
|
||||
"threesome_act",
|
||||
"group_act",
|
||||
)
|
||||
|
||||
|
||||
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 value_texts(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
text = clean_text(value).strip(" .")
|
||||
return [text] if text and text.lower() not in PLACEHOLDER_VALUES else []
|
||||
if isinstance(value, (int, float, bool)) or value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
texts: list[str] = []
|
||||
for item in value:
|
||||
texts.extend(value_texts(item))
|
||||
return texts
|
||||
if isinstance(value, dict):
|
||||
for preferred in PREFERRED_VALUE_KEYS:
|
||||
preferred_texts = value_texts(value.get(preferred))
|
||||
if preferred_texts:
|
||||
return preferred_texts
|
||||
texts: list[str] = []
|
||||
for item in value.values():
|
||||
texts.extend(value_texts(item))
|
||||
return texts
|
||||
return []
|
||||
|
||||
|
||||
def axis_value_texts(
|
||||
axis_values: Any,
|
||||
*,
|
||||
priority: tuple[str, ...] = (),
|
||||
include_unprioritized: bool = True,
|
||||
skip_keys: set[str] | frozenset[str] | tuple[str, ...] = (),
|
||||
existing_text: Any = "",
|
||||
) -> list[str]:
|
||||
if not isinstance(axis_values, dict):
|
||||
return []
|
||||
skipped = {str(key) for key in skip_keys}
|
||||
keys: list[str] = []
|
||||
for key in priority:
|
||||
if key in axis_values and key not in skipped and key not in keys:
|
||||
keys.append(key)
|
||||
if include_unprioritized:
|
||||
for key in axis_values:
|
||||
if key not in skipped and key not in keys:
|
||||
keys.append(key)
|
||||
|
||||
existing = clean_text(existing_text).lower()
|
||||
texts: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for key in keys:
|
||||
for text in value_texts(axis_values.get(key)):
|
||||
normalized = clean_text(text).strip(" .")
|
||||
lower = normalized.lower()
|
||||
if not normalized or lower in seen or (existing and lower in existing):
|
||||
continue
|
||||
texts.append(normalized)
|
||||
seen.add(lower)
|
||||
return texts
|
||||
|
||||
|
||||
def action_context_text(axis_values: Any) -> str:
|
||||
return " ".join(
|
||||
axis_value_texts(
|
||||
axis_values,
|
||||
priority=ACTION_CONTEXT_PRIORITY,
|
||||
include_unprioritized=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def context_text(*parts: Any, axis_values: Any = None) -> str:
|
||||
text_parts = [clean_text(part) for part in parts if clean_text(part)]
|
||||
text_parts.extend(axis_value_texts(axis_values))
|
||||
return " ".join(part.lower() for part in text_parts if part)
|
||||
|
||||
|
||||
def key_text(axis_values: Any, key: str) -> str:
|
||||
if not isinstance(axis_values, dict):
|
||||
return ""
|
||||
values = value_texts(axis_values.get(key))
|
||||
return values[0].lower() if values else ""
|
||||
|
||||
|
||||
def row_axis_value_texts(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
skip_keys: set[str] | frozenset[str] | tuple[str, ...] = (),
|
||||
existing_text: Any = "",
|
||||
) -> list[str]:
|
||||
if not isinstance(row, dict):
|
||||
return []
|
||||
return axis_value_texts(row.get("item_axis_values"), skip_keys=skip_keys, existing_text=existing_text)
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .krea_action_context import axis_values_text
|
||||
from .krea_action_positions import action_position_phrase, mentions_rear_entry
|
||||
from .krea_detail import detail_clauses, join_detail_clauses, limit_detail_for_density
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from krea_action_context import axis_values_text
|
||||
from krea_action_positions import action_position_phrase, mentions_rear_entry
|
||||
from krea_detail import detail_clauses, join_detail_clauses, limit_detail_for_density
|
||||
|
||||
|
||||
def _clean(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 normalize_climax_view_clause(clause: str, role_graph: str) -> str:
|
||||
lower = clause.lower()
|
||||
if "view" not in lower and "frame" not in lower:
|
||||
return clause
|
||||
angle_match = re.search(
|
||||
r"\b(front-facing|close-up|wide full-body|wide|overhead|mirror-reflected|low-angle|side-profile|bed-level)\b",
|
||||
lower,
|
||||
)
|
||||
if not angle_match:
|
||||
return clause
|
||||
angle = angle_match.group(1)
|
||||
if angle == "wide":
|
||||
angle = "wide full-body"
|
||||
position = action_position_phrase(role_graph)
|
||||
if position:
|
||||
return f"{angle} aftermath view with the {position} readable"
|
||||
return f"{angle} aftermath view"
|
||||
|
||||
|
||||
def climax_clause_duplicates_role(clause: str, role_graph: str) -> bool:
|
||||
clause_lower = clause.lower()
|
||||
role_lower = role_graph.lower()
|
||||
role_has_ejaculation = any(token in role_lower for token in ("ejaculates semen", "visible semen", "semen lands"))
|
||||
if role_has_ejaculation and re.search(
|
||||
r"\b(?:cum clearly visible|explicit semen aftermath visible|hardcore ejaculation detail visible|"
|
||||
r"post-ejaculation fluids anatomically clear|sexual fluids and body contact visible|"
|
||||
r"visible external ejaculation|hardcore ejaculation scene|visible orgasm aftermath)\b",
|
||||
clause_lower,
|
||||
):
|
||||
return True
|
||||
duplicate_pairs = (
|
||||
(("lower back", "ass"), ("lower back", "ass")),
|
||||
(("ass",), ("ass",)),
|
||||
(("pussy", "thigh"), ("pussy", "thigh")),
|
||||
(("face", "lips"), ("face", "lips")),
|
||||
(("tongue", "chin"), ("face", "lips", "mouth", "tongue")),
|
||||
(("breast",), ("breast", "chest")),
|
||||
(("belly",), ("belly", "torso")),
|
||||
(("body",), ("body",)),
|
||||
)
|
||||
if any(token in clause_lower for token in ("cum", "semen", "fluid")):
|
||||
for clause_tokens, role_tokens in duplicate_pairs:
|
||||
if any(token in clause_lower for token in clause_tokens) and any(token in role_lower for token in role_tokens):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def climax_role_graph(role_graph: str, hard_item: str, axis_values: Any = None) -> str:
|
||||
role_graph = _clean(role_graph).rstrip(".")
|
||||
text = " ".join(part.lower() for part in (role_graph, _clean(hard_item), axis_values_text(axis_values)) if part)
|
||||
if "the woman" not in text or "the man" not in text:
|
||||
return role_graph
|
||||
if "lying between two partners" in text or "lies between" in text:
|
||||
return "the woman lies between two partners, the man under her hips and another partner over her torso as visible semen lands on her body"
|
||||
if "held between front-and-back partners" in text:
|
||||
return "the woman is held between the man behind her and another partner in front of her as visible semen lands across her body"
|
||||
if "kneeling between standing partners" in text:
|
||||
return "the woman kneels between standing partners gathered around her face and torso for visible ejaculation"
|
||||
if "side-lying with thighs parted" in text:
|
||||
return "the woman lies on her side with thighs parted while the man kneels beside her hips and ejaculates semen across her thighs and pussy"
|
||||
if "sitting on the edge of the bed" in text:
|
||||
return "the woman sits on the edge of the bed with knees spread while the man stands close between her legs and ejaculates semen across her body"
|
||||
if "lying at the bed edge with thighs open" in text:
|
||||
return "the woman lies at the bed edge with thighs open while the man kneels between her legs and ejaculates semen across her pussy and thighs"
|
||||
if "reclining with thighs open" in text or "lying on the back with legs spread" in text:
|
||||
return "the woman lies on her back with thighs open while the man kneels between her legs and ejaculates semen across her pussy and thighs"
|
||||
if "on all fours with hips raised" in text:
|
||||
return "the woman is on all fours with hips raised while the man is positioned behind her and ejaculates semen across her ass, thighs, and lower back"
|
||||
if "face-down ass-up" in text or "lies face-down" in text or "face down" in text:
|
||||
return "the woman lies face-down with ass raised while the man is positioned behind her and ejaculates semen across her lower back and ass"
|
||||
if "bent over with ass raised" in text or "bent over" in text:
|
||||
return "the woman bends forward with hips raised while the man stands behind her with visible semen across her lower back, ass, and thighs"
|
||||
if "kneeling with mouth open" in text:
|
||||
return "the woman kneels in front of the man at hip height as he ejaculates semen onto her face, lips, and chest"
|
||||
if "kneeling in front of a standing partner" in text:
|
||||
return "the woman kneels in front of the man at hip height while he stands over her for visible ejaculation"
|
||||
if "standing with cum on the body" in text:
|
||||
return "the woman stands braced in front of the man while he stands close at hip level and ejaculates semen across her body"
|
||||
if "squatting on top of a partner" in text:
|
||||
return "the woman squats over the man's hips while the man lies on his back under her and ejaculates semen onto her body"
|
||||
if "reverse cowgirl over a partner's hips" in text:
|
||||
return "the woman straddles the man's hips facing away while the man lies on his back under her and ejaculates semen onto her body"
|
||||
if "straddles" in text or "straddling a partner" in text or "straddling a partner's hips" in text or "shared climax after penetration" in text:
|
||||
return "the woman straddles the man's hips while the man lies on his back under her and ejaculates semen onto her body"
|
||||
if "seated in a partner's lap facing them" in text:
|
||||
return "the woman sits in the man's lap facing him, legs wrapped around his hips as he ejaculates semen across her body"
|
||||
if "lower back" in text or "cum dripping from ass" in text or "cum on lower back" in text or mentions_rear_entry(text):
|
||||
return "the woman bends forward with hips raised while the man stands behind her with visible semen across her lower back, ass, and thighs"
|
||||
if "cum on face" in text or "cum on tongue" in text or "cum on lips" in text or "cum on tongue and chin" in text:
|
||||
return "the woman kneels in front of the man at hip height as he ejaculates semen onto her face, lips, and chest"
|
||||
if (
|
||||
"cum dripping from pussy" in text
|
||||
or "arousal dripping from pussy" in text
|
||||
or "open thighs" in text
|
||||
):
|
||||
return "the woman lies on her back with thighs open while the man kneels between her legs and ejaculates semen across her pussy and thighs"
|
||||
if role_graph:
|
||||
return role_graph
|
||||
return "the woman lies on her back with thighs open while the man kneels between her legs and ejaculates semen across her body"
|
||||
|
||||
|
||||
def dedupe_climax_detail(detail: str, role_graph: str, density: str = "balanced") -> str:
|
||||
detail = _clean(detail)
|
||||
lower = role_graph.lower()
|
||||
patterns: list[str] = []
|
||||
if "solo visible ejaculation" in lower or "one hand on his penis" in lower:
|
||||
detail = re.sub(r"\bcum on lower back and ass\b", "visible semen on skin", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bcum (?:on|dripping from) ass\b", "visible semen on skin", detail, flags=re.IGNORECASE)
|
||||
if "lies on her back" in lower:
|
||||
patterns.extend((r"lying on the back with legs spread and hips lifted", r"reclining with thighs open", r"lying on the back with legs spread"))
|
||||
detail = re.sub(r"\bcum on lower back and ass\b", "cum across thighs and pussy", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bcum (?:on|dripping from) ass\b", "cum across thighs and pussy", detail, flags=re.IGNORECASE)
|
||||
if "straddles" in lower:
|
||||
patterns.extend(
|
||||
(
|
||||
r"straddling a partner's hips in cowgirl position",
|
||||
r"reverse cowgirl over a partner's hips",
|
||||
r"straddling a partner",
|
||||
r"squatting on top of a partner",
|
||||
)
|
||||
)
|
||||
if "squats over" in lower:
|
||||
patterns.append(r"squatting on top of a partner")
|
||||
if "sits in the man's lap" in lower:
|
||||
patterns.append(r"seated in a partner's lap facing them")
|
||||
if "bends forward" in lower:
|
||||
patterns.append(r"bent over with ass raised")
|
||||
if "on all fours" in lower:
|
||||
patterns.append(r"on all fours with hips raised")
|
||||
if "face-down" in lower:
|
||||
patterns.append(r"face-down ass-up on the mattress")
|
||||
if "lies on her side" in lower:
|
||||
patterns.append(r"side-lying with thighs parted")
|
||||
detail = re.sub(r"\bcum on lower back and ass\b", "cum across thighs and pussy", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bcum (?:on|dripping from) ass\b", "cum across thighs and pussy", detail, flags=re.IGNORECASE)
|
||||
if "sits on the edge" in lower:
|
||||
patterns.append(r"sitting on the edge of the bed")
|
||||
if "bed edge" in lower:
|
||||
patterns.append(r"lying at the bed edge with thighs open")
|
||||
if "kneels in front" in lower:
|
||||
patterns.extend((r"kneeling with mouth open", r"kneeling in front of a standing partner"))
|
||||
if "stands braced" in lower:
|
||||
patterns.append(r"standing with cum on the body")
|
||||
for pattern in patterns:
|
||||
detail = re.sub(rf"\b{pattern}\b,?\s*", "", detail, flags=re.IGNORECASE)
|
||||
if not any(token in lower for token in ("face", "mouth", "lips", "tongue")):
|
||||
detail = re.sub(r"\bsaliva and cum mixed on the mouth\b", "visible semen on skin", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bcum on tongue and chin\b", "visible semen on skin", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bcum on face and lips\b", "visible semen on skin", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r",\s*,", ",", detail)
|
||||
detail = re.sub(r"\bwith\s*,\s*", "with ", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"^with\s+", "", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"^and\s+", "", detail, flags=re.IGNORECASE)
|
||||
clauses: list[str] = []
|
||||
for clause in detail_clauses(detail):
|
||||
normalized = normalize_climax_view_clause(clause, role_graph)
|
||||
if climax_clause_duplicates_role(normalized, role_graph):
|
||||
continue
|
||||
if density != "dense" and normalized.lower() in ("orgasm during penetration", "post-orgasm visible release"):
|
||||
continue
|
||||
clauses.append(normalized)
|
||||
return limit_detail_for_density(join_detail_clauses(clauses), density, True)
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
HARDCORE_DETAIL_DENSITY_CHOICES = {"compact", "balanced", "dense"}
|
||||
|
||||
|
||||
def _clean(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 normalize_hardcore_detail_density(value: Any) -> str:
|
||||
text = _clean(value).lower()
|
||||
return text if text in HARDCORE_DETAIL_DENSITY_CHOICES else "balanced"
|
||||
|
||||
|
||||
def axis_values_text(axis_values: Any) -> str:
|
||||
return item_axis_policy.action_context_text(axis_values)
|
||||
|
||||
|
||||
def position_context_text(role_graph: str, hard_item: str, composition: str = "", axis_values: Any = None) -> str:
|
||||
return " ".join(
|
||||
_clean(part).lower()
|
||||
for part in (role_graph, hard_item, composition, axis_values_text(axis_values))
|
||||
if _clean(part)
|
||||
)
|
||||
|
||||
|
||||
def is_outercourse_text(*parts: Any) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
return any(
|
||||
term in text
|
||||
for term in (
|
||||
"outercourse",
|
||||
"non-penetrative",
|
||||
"boobjob",
|
||||
"titjob",
|
||||
"breast sex",
|
||||
"breast-sex",
|
||||
"testicle",
|
||||
"balls",
|
||||
"balls licking",
|
||||
"balls-licking",
|
||||
"breasts tightly around",
|
||||
"breasts around",
|
||||
"penis licking",
|
||||
"penis-licking",
|
||||
"tongue along",
|
||||
"tongue runs along",
|
||||
"tongue running along",
|
||||
"handjob",
|
||||
"hand job",
|
||||
"hand wrapped",
|
||||
"hand stroking",
|
||||
"hand wraps around",
|
||||
"manual stimulation",
|
||||
"fingering",
|
||||
"fingers inside",
|
||||
"fingers in pussy",
|
||||
"hand on pussy",
|
||||
"fingers on pussy",
|
||||
"fingers sliding against the pussy",
|
||||
"open-thigh manual",
|
||||
"clit rubbing",
|
||||
"clit",
|
||||
"clitoris",
|
||||
"mutual masturbation",
|
||||
"footjob",
|
||||
"soles wrap around",
|
||||
"soles",
|
||||
"toes curled",
|
||||
"feet stroking",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_oral_text(*parts: Any) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
return any(
|
||||
term in text
|
||||
for term in (
|
||||
"oral",
|
||||
"fellatio",
|
||||
"blowjob",
|
||||
"deepthroat",
|
||||
"penis sucking",
|
||||
"penis in her mouth",
|
||||
"penis in mouth",
|
||||
"takes the man's penis",
|
||||
"takes his penis",
|
||||
"mouth at penis level",
|
||||
"mouth on his penis",
|
||||
"lips wrapped",
|
||||
"cunnilingus",
|
||||
"pussy licking",
|
||||
"mouth on her pussy",
|
||||
"mouth pressed to her pussy",
|
||||
"face-sitting",
|
||||
"sixty-nine",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_foreplay_text(*parts: Any) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
if not text:
|
||||
return False
|
||||
return any(
|
||||
term in text
|
||||
for term in (
|
||||
"foreplay",
|
||||
"pre-sex",
|
||||
"before sex",
|
||||
"before penetration",
|
||||
"kissing",
|
||||
"deep kiss",
|
||||
"mouth-to-mouth",
|
||||
"lips pressed",
|
||||
"caressing",
|
||||
"hands roaming",
|
||||
"stroking skin",
|
||||
"touching breasts",
|
||||
"cupping a breast",
|
||||
"hand on the cheek",
|
||||
"cheek and jaw",
|
||||
"fingers under the chin",
|
||||
"undressing",
|
||||
"removing clothing",
|
||||
"removing clothes",
|
||||
"pulling clothing",
|
||||
"sliding straps",
|
||||
"unbuttoning",
|
||||
"body worship",
|
||||
"nipple",
|
||||
"mouth on skin",
|
||||
"kissing down",
|
||||
"ass grabbing",
|
||||
"gripping the ass",
|
||||
"thigh kissing",
|
||||
"inner thighs",
|
||||
"hair held",
|
||||
"holding hair",
|
||||
"hair pulled back",
|
||||
"wrist",
|
||||
"wrists",
|
||||
"pinning",
|
||||
"guided",
|
||||
"guiding",
|
||||
"turning the body",
|
||||
"position transition",
|
||||
"pulling onto the bed",
|
||||
"lifting and spreading",
|
||||
"spreading thighs",
|
||||
"dirty talk",
|
||||
"whispering",
|
||||
"camera performance",
|
||||
"presented directly to the camera",
|
||||
"present her body",
|
||||
"showing to camera",
|
||||
"spread open for the camera",
|
||||
"watching partner",
|
||||
"waiting turn",
|
||||
"group coordination",
|
||||
"aftercare",
|
||||
"cleanup",
|
||||
"wiping",
|
||||
"towel",
|
||||
"post-sex",
|
||||
"fingering",
|
||||
"fingers inside",
|
||||
"hand on pussy",
|
||||
"fingers on pussy",
|
||||
"clit rubbing",
|
||||
"clit",
|
||||
"clitoris",
|
||||
"manual stimulation",
|
||||
"mutual masturbation",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_close_foreplay_text(*parts: Any) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
if not text or not is_foreplay_text(text):
|
||||
return False
|
||||
return any(
|
||||
term in text
|
||||
for term in (
|
||||
"stand close",
|
||||
"stand face-to-face",
|
||||
"press their bodies",
|
||||
"bodies pressed close",
|
||||
"hips pressed close",
|
||||
"mouth-to-mouth",
|
||||
"deep kissing",
|
||||
"heated kiss",
|
||||
"hands pull clothing",
|
||||
"pull clothing aside",
|
||||
"clothing being removed",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_vaginal_penetration_text(*parts: Any) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
if not text or is_outercourse_text(text) or is_oral_text(text) or is_foreplay_text(text):
|
||||
return False
|
||||
if any(term in text for term in ("anal", "double penetration", "double-penetration", "toy-assisted", "strap-on")):
|
||||
return False
|
||||
return any(
|
||||
term in text
|
||||
for term in (
|
||||
"vaginal penetration",
|
||||
"deep vaginal sex",
|
||||
"explicit penetrative sex",
|
||||
"penetrative sex",
|
||||
"penis entering pussy",
|
||||
"penis thrusts into her pussy",
|
||||
"penis thrusts into the woman",
|
||||
"pussy stretched around a penis",
|
||||
"hardcore vaginal thrusting",
|
||||
"full-body penetrative sex",
|
||||
"close-contact vaginal sex",
|
||||
"missionary position",
|
||||
"cowgirl position",
|
||||
"reverse cowgirl position",
|
||||
"doggy style position",
|
||||
"standing sex position",
|
||||
"spooning sex position",
|
||||
"edge-of-bed position",
|
||||
"kneeling straddle position",
|
||||
"lotus sex position",
|
||||
"bent-over position",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_toy_assisted_double_text(*parts: Any) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
return any(
|
||||
token in text
|
||||
for token in (
|
||||
"double penetration",
|
||||
"double-penetration",
|
||||
"front-and-back double",
|
||||
"vaginal and anal penetration",
|
||||
"pussy and ass filled",
|
||||
"one penis in pussy and one penis in ass",
|
||||
"second penetration point",
|
||||
"second point of contact",
|
||||
"second contact",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_climax_text(*parts: str) -> bool:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
return any(
|
||||
token in text
|
||||
for token in (
|
||||
"cumshot",
|
||||
"ejaculation",
|
||||
"post-orgasm",
|
||||
"post-climax",
|
||||
"orgasm aftermath",
|
||||
"orgasm scene",
|
||||
"orgasm during",
|
||||
"shared climax",
|
||||
"hardcore climax",
|
||||
"external cumshot",
|
||||
"visible external ejaculation",
|
||||
"climaxes on",
|
||||
"climax lands",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,472 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import outercourse_action_policy as outercourse_policy
|
||||
from .krea_action_context import (
|
||||
is_close_foreplay_text,
|
||||
position_context_text,
|
||||
)
|
||||
from .krea_detail import detail_clauses, join_detail_clauses
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
import outercourse_action_policy as outercourse_policy
|
||||
from krea_action_context import (
|
||||
is_close_foreplay_text,
|
||||
position_context_text,
|
||||
)
|
||||
from krea_detail import detail_clauses, join_detail_clauses
|
||||
|
||||
|
||||
def _clean(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 strip_redundant_position_detail(detail: str) -> str:
|
||||
detail = _clean(detail)
|
||||
if not detail:
|
||||
return ""
|
||||
detail = re.sub(
|
||||
r"^\s*[^,;]*?\bposition\b\s+(?:while|featuring|with)\s+",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"^\s*[^,;]*?\bposition\b,\s*",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"\s+\bin\s+[^,;]*?\bposition\b",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"\s+\bfrom\s+[^,;]*?\bposition\b",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(r"\s*,\s*", ", ", detail)
|
||||
detail = re.sub(r",\s*,", ",", detail)
|
||||
return _clean(detail).strip(" ,;")
|
||||
|
||||
|
||||
def sanitize_foreplay_detail(detail: str, role_graph: str = "", composition: str = "") -> str:
|
||||
detail = strip_redundant_position_detail(detail)
|
||||
if not detail:
|
||||
return ""
|
||||
if not is_close_foreplay_text(role_graph, detail, composition):
|
||||
return detail
|
||||
detail = re.sub(
|
||||
r"\b(?:raised edge|edge-supported|edge-of-bed|bed-edge)\s+undressing position\s+(?:featuring|while|with)\s+",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"\b(?:standing kissing|wall-pressed kissing|mirror undressing)\s+position\s+(?:featuring|while|with)\s+",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"\b(?:raised edge|edge-supported|edge-of-bed|bed-edge)\s+undressing position\b",
|
||||
"close standing undressing",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(r"\braised-edge open-thigh position\b", "close-body first-person position", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\s*,\s*", ", ", detail).strip(" ,;")
|
||||
return _clean(detail)
|
||||
|
||||
|
||||
def hardcore_item_detail(hard_item: str) -> str:
|
||||
text = _clean(hard_item).rstrip(".")
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"^hardcore\s+", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^explicit\s+", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^(?:orgasm|climax)\s+scene:\s*", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^(?:mouth-to-genitals|double-contact sex|adult group pile|sex pile)\s+pose:\s*", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^(?:oral|threesome|orgy)\s+scene\s+with\s+", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^(?:threesome|orgy)\s+pose:\s*", "", text, flags=re.IGNORECASE)
|
||||
act_patterns = (
|
||||
r"(?:penis and toy|toy and strap-on|toy-assisted|front-and-back|hardcore|deep|kneeling|standing supported)?\s*double penetration",
|
||||
r"toy-assisted vaginal and anal penetration at the same time",
|
||||
r"vaginal and anal penetration at the same time",
|
||||
r"one penis in pussy and one penis in ass",
|
||||
r"anal penetration with visible genital contact",
|
||||
r"rear-entry anal penetration",
|
||||
r"anal sex with spread cheeks",
|
||||
r"ass stretched around a penis",
|
||||
r"penis entering ass",
|
||||
r"deep anal sex",
|
||||
r"bent-over anal sex",
|
||||
r"hardcore anal thrusting",
|
||||
r"vaginal penetration with visible genital contact",
|
||||
r"penis entering pussy",
|
||||
r"pussy stretched around a penis",
|
||||
r"deep vaginal sex",
|
||||
r"explicit penetrative sex",
|
||||
r"penetrative sex",
|
||||
r"hardcore vaginal thrusting",
|
||||
r"full-body penetrative sex",
|
||||
r"close-contact vaginal sex",
|
||||
r"fellatio with penis in mouth",
|
||||
r"deepthroat blowjob",
|
||||
r"blowjob",
|
||||
r"penis sucking with visible saliva",
|
||||
r"cunnilingus with tongue on pussy",
|
||||
r"face-sitting cunnilingus",
|
||||
r"pussy licking with thighs spread",
|
||||
r"oral sex with tongue and fingers",
|
||||
r"oral contact with mouth on the visible genitals",
|
||||
r"sixty-nine oral sex",
|
||||
)
|
||||
act_pattern = "|".join(act_patterns)
|
||||
position_pattern = (
|
||||
r"missionary position|cowgirl position|reverse cowgirl position|doggy style position|"
|
||||
r"standing sex position|spooning sex position|edge-of-bed position|kneeling straddle position|"
|
||||
r"lotus sex position|bent-over position|kneeling oral position|face-sitting position|"
|
||||
r"sixty-nine position|edge-of-bed oral position|edge-supported oral position|standing oral position|reclining cunnilingus position|"
|
||||
r"straddled oral position|side-lying oral position|spread-leg oral position|chair oral position"
|
||||
)
|
||||
text = re.sub(
|
||||
rf"^({position_pattern})\s+(?:while|with|featuring)\s+(?:{act_pattern})\s*,?\s*",
|
||||
r"\1, ",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(
|
||||
rf"^(?:{act_pattern})\s*(?:in|from|on|with|while|featuring)?\s*",
|
||||
"",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(r"^(?:position|pose)\s+", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^with\s+", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bwith with\b", "with", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r",\s*with\s+", ", ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r",\s+and\s+", ", ", text)
|
||||
text = re.sub(r"\s*,\s*", ", ", text).strip(" ,;")
|
||||
return _clean(text)
|
||||
|
||||
|
||||
def dedupe_anchor_detail(detail: str, anchor: str) -> str:
|
||||
detail = strip_redundant_position_detail(detail)
|
||||
anchor_lower = anchor.lower()
|
||||
duplicate_phrases = {
|
||||
"front-and-back": (r"front-and-back contact",),
|
||||
"side-lying oral": (r"side-lying oral position",),
|
||||
"kneeling oral": (r"kneeling oral position",),
|
||||
"face-sitting": (r"face-sitting position",),
|
||||
"sixty-nine": (
|
||||
r"sixty-nine position",
|
||||
r"sixty-nine oral sex",
|
||||
r"kneeling oral position",
|
||||
r"face-sitting position",
|
||||
r"edge-of-bed oral position",
|
||||
r"standing oral position",
|
||||
r"reclining cunnilingus position",
|
||||
r"straddled oral position",
|
||||
r"side-lying oral position",
|
||||
r"spread-leg oral position",
|
||||
r"chair oral position",
|
||||
),
|
||||
"edge-supported oral": (r"edge-of-bed oral position", r"edge-supported oral position"),
|
||||
"edge-of-bed oral": (r"edge-of-bed oral position", r"edge-supported oral position"),
|
||||
"standing oral": (r"standing oral position",),
|
||||
"spread-leg oral": (r"spread-leg oral position",),
|
||||
"chair oral": (r"chair oral position",),
|
||||
"reclining cunnilingus": (r"reclining cunnilingus position",),
|
||||
"straddled cunnilingus": (r"straddled oral position", r"straddled cunnilingus position"),
|
||||
"open-thigh cunnilingus": (r"reclining cunnilingus position", r"straddled cunnilingus position"),
|
||||
"bent-over": (r"bent-over position",),
|
||||
"face-down": (r"face-down ass-up position",),
|
||||
"missionary": (r"missionary position",),
|
||||
"reverse cowgirl": (r"reverse cowgirl position",),
|
||||
"cowgirl": (r"cowgirl position",),
|
||||
"doggy-style": (r"doggy style position",),
|
||||
"edge-supported": (r"edge-of-bed position", r"edge-supported position", r"raised edge position"),
|
||||
"edge-of-bed": (r"edge-of-bed position", r"edge-supported position"),
|
||||
"lotus": (r"lotus sex position",),
|
||||
"standing sex": (r"standing sex position",),
|
||||
"spooning": (r"spooning sex position", r"spooning anal position"),
|
||||
}
|
||||
for anchor_token, phrases in duplicate_phrases.items():
|
||||
if anchor_token in anchor_lower:
|
||||
for phrase in phrases:
|
||||
detail = re.sub(rf"\b{phrase}\b,?\s*", "", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"^\s*,\s*", "", detail)
|
||||
detail = re.sub(r",\s*,", ",", detail)
|
||||
return _clean(detail).strip(" ,;")
|
||||
|
||||
|
||||
def dedupe_toy_double_detail(detail: str) -> str:
|
||||
detail = _clean(detail)
|
||||
if not detail:
|
||||
return ""
|
||||
angle_view = (
|
||||
r"(?:rear-view|side-profile|low-angle|mirror-reflected|overhead|close-up|wide full-body|front-facing with hips turned)"
|
||||
)
|
||||
toy_act = (
|
||||
r"(?:penis and toy double penetration|toy-assisted vaginal and anal penetration at the same time|toy and strap-on double penetration)"
|
||||
)
|
||||
detail = re.sub(
|
||||
rf"\b({angle_view}\s+view of\s+){toy_act}\b",
|
||||
r"\1the rear-entry contact",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(rf",?\s*\b{toy_act}\b", "", detail, flags=re.IGNORECASE)
|
||||
duplicate_phrases = (
|
||||
"toy-assisted second contact aligned behind the body",
|
||||
"toy aligned for a second penetration point",
|
||||
"rear-entry body alignment",
|
||||
"close body alignment",
|
||||
"stacked bodies in close contact",
|
||||
"one body between two partners",
|
||||
"one partner behind and one partner in front",
|
||||
"two partners penetrating at once",
|
||||
"one partner held between two bodies",
|
||||
"front-and-back contact",
|
||||
"three bodies locked together",
|
||||
"kneeling center partner",
|
||||
)
|
||||
for phrase in duplicate_phrases:
|
||||
detail = re.sub(rf",?\s*\b{re.escape(phrase)}\b", "", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"^\s*,\s*", "", detail)
|
||||
detail = re.sub(r",\s*,", ",", detail)
|
||||
return _clean(detail).strip(" ,;")
|
||||
|
||||
|
||||
def dedupe_outercourse_detail(detail: str, role_graph: str, hard_item: str = "", axis_values: Any = None) -> str:
|
||||
detail = strip_redundant_position_detail(detail)
|
||||
if not detail:
|
||||
return ""
|
||||
context = position_context_text(role_graph, hard_item, "", axis_values)
|
||||
context_lower = context.lower()
|
||||
position_text = ""
|
||||
if isinstance(axis_values, dict):
|
||||
position_text = _clean(axis_values.get("position", "")).lower()
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(position_text)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_GENERIC:
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(context_lower)
|
||||
clauses: list[str] = []
|
||||
for clause in detail_clauses(detail):
|
||||
lower = clause.lower()
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB:
|
||||
if lower in ("penis", "breasts", "mouth clearly visible"):
|
||||
continue
|
||||
if any(
|
||||
term in lower
|
||||
for term in (
|
||||
"boobjob",
|
||||
"titjob",
|
||||
"breast-sex",
|
||||
"breast sex",
|
||||
"seated titjob position",
|
||||
"kneeling boobjob position",
|
||||
"tight close-up breast-sex position",
|
||||
"penis shaft compressed between breasts",
|
||||
"penis squeezed between both breasts",
|
||||
"hands pressing the breasts tightly",
|
||||
"hands pressing breasts firmly together",
|
||||
"fingers spreading the breasts around the penis shaft",
|
||||
"soft flesh squeezed around the penis shaft",
|
||||
"hand wrapped around the penis shaft",
|
||||
"glans near the mouth",
|
||||
"glans visible",
|
||||
"penis, breasts, and mouth clearly visible",
|
||||
)
|
||||
):
|
||||
continue
|
||||
elif action_kind == outercourse_policy.OUTERCOURSE_TESTICLE:
|
||||
if any(
|
||||
term in lower
|
||||
for term in (
|
||||
"testicle",
|
||||
"balls licking",
|
||||
"balls-licking",
|
||||
"balls held",
|
||||
"balls close",
|
||||
"balls and mouth",
|
||||
"mouth and tongue",
|
||||
"mouth visible",
|
||||
"mouth contact",
|
||||
)
|
||||
):
|
||||
continue
|
||||
elif action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING:
|
||||
if any(
|
||||
term in lower
|
||||
for term in (
|
||||
"penis licking",
|
||||
"penis-licking",
|
||||
"tongue along",
|
||||
"tongue licking",
|
||||
"underside of the penis",
|
||||
"tongue contact on the penis",
|
||||
"one hand steadies the base",
|
||||
)
|
||||
):
|
||||
continue
|
||||
elif action_kind == outercourse_policy.OUTERCOURSE_HANDJOB:
|
||||
if any(
|
||||
term in lower
|
||||
for term in (
|
||||
"handjob",
|
||||
"hand job",
|
||||
"hand wrapped",
|
||||
"one hand wrapped",
|
||||
"two-handed",
|
||||
"both hands stroking",
|
||||
"hand and penis centered",
|
||||
"fingers and palm visibly stroking",
|
||||
)
|
||||
):
|
||||
continue
|
||||
elif action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB:
|
||||
if any(
|
||||
term in lower
|
||||
for term in (
|
||||
"footjob",
|
||||
"foot job",
|
||||
"both soles",
|
||||
"soles pressing",
|
||||
"soles wrap",
|
||||
"toes curled",
|
||||
"feet and penis",
|
||||
"soles and penis",
|
||||
)
|
||||
):
|
||||
continue
|
||||
clauses.append(clause)
|
||||
return join_detail_clauses(clauses)
|
||||
|
||||
|
||||
def dedupe_oral_detail(detail: str, role_graph: str, hard_item: str = "", axis_values: Any = None) -> str:
|
||||
detail = _clean(detail)
|
||||
if not detail:
|
||||
return ""
|
||||
context = position_context_text(role_graph, hard_item, "", axis_values)
|
||||
woman_gives = any(
|
||||
term in context
|
||||
for term in (
|
||||
"takes the man's penis",
|
||||
"takes his penis",
|
||||
"penis in her mouth",
|
||||
"mouth at penis level",
|
||||
"mouth on his penis",
|
||||
"fellatio",
|
||||
"blowjob",
|
||||
"deepthroat",
|
||||
"penis sucking",
|
||||
)
|
||||
)
|
||||
clauses: list[str] = []
|
||||
for clause in detail_clauses(detail):
|
||||
lower = clause.lower()
|
||||
if any(
|
||||
term in lower
|
||||
for term in (
|
||||
"kneeling oral position",
|
||||
"standing oral position",
|
||||
"edge-of-bed oral position",
|
||||
"side-lying oral position",
|
||||
"chair oral position",
|
||||
"reclining cunnilingus position",
|
||||
"face-sitting position",
|
||||
"sixty-nine position",
|
||||
"fellatio with penis in mouth",
|
||||
"deepthroat blowjob",
|
||||
"penis sucking with visible saliva",
|
||||
"cunnilingus with tongue on pussy",
|
||||
"oral sex with tongue and fingers",
|
||||
"oral contact with mouth on the visible genitals",
|
||||
"bodies stacked close together",
|
||||
"body angle keeps the penis and face readable",
|
||||
)
|
||||
):
|
||||
continue
|
||||
if woman_gives and lower == "wet shine on genitals":
|
||||
clause = "saliva dripping on the penis"
|
||||
clauses.append(clause)
|
||||
return join_detail_clauses(clauses)
|
||||
|
||||
|
||||
def dedupe_penetration_detail(detail: str, role_graph: str, hard_item: str = "", axis_values: Any = None) -> str:
|
||||
detail = _clean(detail)
|
||||
if not detail:
|
||||
return ""
|
||||
role_lower = _clean(role_graph).lower()
|
||||
detail = re.sub(
|
||||
r"\b(?:front-facing|side-profile|rear-view|overhead|mirror-reflected|low-angle|close-up|wide full-body)\s+view of\s+"
|
||||
r"(?:vaginal penetration with visible genital contact|deep vaginal sex|explicit penetrative sex|penetrative sex|"
|
||||
r"penis entering pussy|pussy stretched around a penis|hardcore vaginal thrusting|full-body penetrative sex|"
|
||||
r"close-contact vaginal sex)\b,?\s*",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
act_terms = (
|
||||
"vaginal penetration with visible genital contact",
|
||||
"deep vaginal sex",
|
||||
"explicit penetrative sex",
|
||||
"penetrative sex",
|
||||
"penis entering pussy",
|
||||
"pussy stretched around a penis",
|
||||
"hardcore vaginal thrusting",
|
||||
"full-body penetrative sex",
|
||||
"close-contact vaginal sex",
|
||||
"missionary position",
|
||||
"cowgirl position",
|
||||
"reverse cowgirl position",
|
||||
"doggy style position",
|
||||
"standing sex position",
|
||||
"spooning sex position",
|
||||
"edge-of-bed position",
|
||||
"kneeling straddle position",
|
||||
"lotus sex position",
|
||||
"bent-over position",
|
||||
)
|
||||
clauses: list[str] = []
|
||||
for clause in detail_clauses(detail):
|
||||
lower = clause.lower()
|
||||
if any(term in lower for term in act_terms):
|
||||
continue
|
||||
if lower in (
|
||||
"tongues visible while kissing",
|
||||
"deep kissing",
|
||||
"mouth close to the ear",
|
||||
"neck kissing",
|
||||
"explicit genital contact visible",
|
||||
"genitals clearly visible",
|
||||
"anatomically clear penetration",
|
||||
"pussy and penis visible",
|
||||
"wetness visible between the thighs",
|
||||
):
|
||||
continue
|
||||
if lower in ("legs spread wide", "thighs open toward the viewer") and any(
|
||||
term in role_lower for term in ("legs spread wide", "thighs open", "open thighs")
|
||||
):
|
||||
continue
|
||||
if lower == "one body pinned under another" and "lies under" in role_lower:
|
||||
continue
|
||||
if lower in ("hips locked tightly together", "hips aligned") and "hips" in role_lower:
|
||||
continue
|
||||
if lower in ("hands gripping hips", "hands spreading the thighs") and any(
|
||||
term in role_lower for term in ("hips", "thighs", "legs")
|
||||
):
|
||||
continue
|
||||
clauses.append(clause)
|
||||
return join_detail_clauses(clauses)
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .krea_action_context import (
|
||||
axis_values_text,
|
||||
is_climax_text,
|
||||
is_toy_assisted_double_text,
|
||||
normalize_hardcore_detail_density,
|
||||
)
|
||||
from .hardcore_action_metadata import (
|
||||
ACTION_ANAL,
|
||||
ACTION_CLIMAX,
|
||||
ACTION_FOREPLAY,
|
||||
ACTION_MANUAL,
|
||||
ACTION_ORAL,
|
||||
ACTION_OUTERCOURSE,
|
||||
ACTION_PENETRATION,
|
||||
ACTION_TOY_DOUBLE,
|
||||
infer_hardcore_action_family,
|
||||
normalize_hardcore_action_family,
|
||||
)
|
||||
from .krea_detail import limit_detail_for_density
|
||||
from .krea_action_positions import hardcore_pose_anchor
|
||||
from .krea_action_details import (
|
||||
dedupe_anchor_detail,
|
||||
dedupe_oral_detail,
|
||||
dedupe_outercourse_detail,
|
||||
dedupe_penetration_detail,
|
||||
dedupe_toy_double_detail,
|
||||
hardcore_item_detail,
|
||||
sanitize_foreplay_detail,
|
||||
)
|
||||
from .krea_action_climax import climax_role_graph, dedupe_climax_detail
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from krea_action_context import (
|
||||
axis_values_text,
|
||||
is_climax_text,
|
||||
is_toy_assisted_double_text,
|
||||
normalize_hardcore_detail_density,
|
||||
)
|
||||
from hardcore_action_metadata import (
|
||||
ACTION_ANAL,
|
||||
ACTION_CLIMAX,
|
||||
ACTION_FOREPLAY,
|
||||
ACTION_MANUAL,
|
||||
ACTION_ORAL,
|
||||
ACTION_OUTERCOURSE,
|
||||
ACTION_PENETRATION,
|
||||
ACTION_TOY_DOUBLE,
|
||||
infer_hardcore_action_family,
|
||||
normalize_hardcore_action_family,
|
||||
)
|
||||
from krea_detail import limit_detail_for_density
|
||||
from krea_action_positions import hardcore_pose_anchor
|
||||
from krea_action_details import (
|
||||
dedupe_anchor_detail,
|
||||
dedupe_oral_detail,
|
||||
dedupe_outercourse_detail,
|
||||
dedupe_penetration_detail,
|
||||
dedupe_toy_double_detail,
|
||||
hardcore_item_detail,
|
||||
sanitize_foreplay_detail,
|
||||
)
|
||||
from krea_action_climax import climax_role_graph, dedupe_climax_detail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardcoreActionParts:
|
||||
family: str
|
||||
role_graph: str
|
||||
hard_item: str
|
||||
detail: str
|
||||
anchor: str
|
||||
detail_density: str
|
||||
|
||||
|
||||
def _clean(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 normalize_hardcore_role_graph(role_graph: str) -> str:
|
||||
role_graph = _clean(role_graph).rstrip(".")
|
||||
replacements = (
|
||||
(
|
||||
r"\bthe man penetrates the woman while a toy adds a second point of contact\b",
|
||||
"the man's penis thrusts into the woman while a toy is positioned at the second penetration point",
|
||||
),
|
||||
(
|
||||
r"\bthe man thrusts his penis into the woman while a toy adds a second penetration point\b",
|
||||
"the man's penis thrusts into the woman while a toy is positioned at the second penetration point",
|
||||
),
|
||||
(
|
||||
r"\bthe man thrusts his penis into the woman\b",
|
||||
"the man's penis thrusts into the woman",
|
||||
),
|
||||
(
|
||||
r"\bthe man penetrates the woman anally\b",
|
||||
"the man's penis thrusts into the woman's ass",
|
||||
),
|
||||
(
|
||||
r"\bthe man thrusts his penis into the woman's ass\b",
|
||||
"the man's penis thrusts into the woman's ass",
|
||||
),
|
||||
(
|
||||
r"\bthe man penetrates the woman\b",
|
||||
"the man's penis thrusts into the woman",
|
||||
),
|
||||
(
|
||||
r"\bthe woman and the man are in mutual oral contact with mouth-to-genital contact visible\b",
|
||||
"the woman has the man's penis in her mouth while the man uses his mouth on her pussy",
|
||||
),
|
||||
(
|
||||
r"\bthe woman gives oral to the man\b",
|
||||
"the woman takes the man's penis in her mouth",
|
||||
),
|
||||
)
|
||||
for pattern, replacement in replacements:
|
||||
role_graph = re.sub(pattern, replacement, role_graph, flags=re.IGNORECASE)
|
||||
return role_graph
|
||||
|
||||
|
||||
def normalize_toy_double_role_graph(role_graph: str) -> str:
|
||||
return re.sub(
|
||||
r"\s+while a toy adds (?:the|a) second penetration point\b",
|
||||
" while a toy is positioned at the second penetration point",
|
||||
role_graph,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def action_detail_for_family(
|
||||
family: str,
|
||||
detail: str,
|
||||
role_graph: str,
|
||||
hard_item: str,
|
||||
composition: str = "",
|
||||
axis_values: Any = None,
|
||||
*,
|
||||
anchor: str = "",
|
||||
detail_density: str = "balanced",
|
||||
) -> tuple[str, str]:
|
||||
if family == ACTION_CLIMAX:
|
||||
return "", dedupe_climax_detail(detail, role_graph, detail_density)
|
||||
if family in (ACTION_FOREPLAY, ACTION_MANUAL):
|
||||
detail = sanitize_foreplay_detail(detail, role_graph, composition)
|
||||
return "", limit_detail_for_density(detail, detail_density, False)
|
||||
if family == ACTION_OUTERCOURSE:
|
||||
detail = dedupe_outercourse_detail(detail, role_graph, hard_item, axis_values)
|
||||
return "", limit_detail_for_density(detail, detail_density, False)
|
||||
if family == ACTION_ORAL and role_graph:
|
||||
detail = dedupe_oral_detail(detail, role_graph, hard_item, axis_values)
|
||||
return "", limit_detail_for_density(detail, detail_density, False)
|
||||
if family in (ACTION_ANAL, ACTION_PENETRATION) and role_graph:
|
||||
detail = dedupe_penetration_detail(detail, role_graph, hard_item, axis_values)
|
||||
return "", limit_detail_for_density(detail, detail_density, False)
|
||||
|
||||
if anchor:
|
||||
detail = dedupe_anchor_detail(detail, anchor)
|
||||
if family == ACTION_TOY_DOUBLE:
|
||||
detail = dedupe_toy_double_detail(detail)
|
||||
return anchor, limit_detail_for_density(detail, detail_density, False)
|
||||
|
||||
|
||||
def resolve_hardcore_action_parts(
|
||||
role_graph: str,
|
||||
hard_item: str,
|
||||
composition: str = "",
|
||||
axis_values: Any = None,
|
||||
detail_density: str = "balanced",
|
||||
action_family: Any = "",
|
||||
) -> HardcoreActionParts:
|
||||
detail_density = normalize_hardcore_detail_density(detail_density)
|
||||
role_graph = normalize_hardcore_role_graph(role_graph)
|
||||
hard_item = _clean(hard_item).rstrip(".")
|
||||
axis_text = axis_values_text(axis_values)
|
||||
forced_family = normalize_hardcore_action_family(action_family)
|
||||
is_climax = forced_family == ACTION_CLIMAX or is_climax_text(role_graph, hard_item, composition, axis_text)
|
||||
if is_climax:
|
||||
role_graph = climax_role_graph(role_graph, hard_item, axis_values)
|
||||
|
||||
detail = hardcore_item_detail(hard_item)
|
||||
anchor = hardcore_pose_anchor(role_graph, hard_item, composition, axis_values)
|
||||
family = forced_family or infer_hardcore_action_family(role_graph, hard_item, composition, axis_values, is_climax=is_climax)
|
||||
|
||||
if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_text):
|
||||
role_graph = normalize_toy_double_role_graph(role_graph)
|
||||
|
||||
anchor, detail = action_detail_for_family(
|
||||
family,
|
||||
detail,
|
||||
role_graph,
|
||||
hard_item,
|
||||
composition,
|
||||
axis_values,
|
||||
anchor=anchor,
|
||||
detail_density=detail_density,
|
||||
)
|
||||
return HardcoreActionParts(
|
||||
family=family,
|
||||
role_graph=role_graph,
|
||||
hard_item=hard_item,
|
||||
detail=detail,
|
||||
anchor=anchor,
|
||||
detail_density=detail_density,
|
||||
)
|
||||
@@ -0,0 +1,480 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import outercourse_action_policy as outercourse_policy
|
||||
from .krea_action_context import (
|
||||
axis_values_text,
|
||||
is_close_foreplay_text,
|
||||
is_foreplay_text,
|
||||
is_outercourse_text,
|
||||
is_toy_assisted_double_text,
|
||||
position_context_text,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
import outercourse_action_policy as outercourse_policy
|
||||
from krea_action_context import (
|
||||
axis_values_text,
|
||||
is_close_foreplay_text,
|
||||
is_foreplay_text,
|
||||
is_outercourse_text,
|
||||
is_toy_assisted_double_text,
|
||||
position_context_text,
|
||||
)
|
||||
|
||||
|
||||
def _clean(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 mentions_rear_entry(text: str) -> bool:
|
||||
return bool(
|
||||
re.search(
|
||||
r"ass[- ](?:up|raised|exposed|lifted|stretched)|penis entering ass|cum (?:on|dripping from) ass|spread cheeks|lower back and ass|pussy, ass|rear[- ]entry",
|
||||
text,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def hardcore_pose_anchor(role_graph: str, hard_item: str, composition: str = "", axis_values: Any = None) -> str:
|
||||
text = position_context_text(role_graph, hard_item, composition, axis_values)
|
||||
item_text = " ".join(part for part in (_clean(hard_item).lower(), axis_values_text(axis_values).lower()) if part)
|
||||
position_text = ""
|
||||
if isinstance(axis_values, dict):
|
||||
position_text = _clean(axis_values.get("position", "")).lower()
|
||||
if not text:
|
||||
return ""
|
||||
if is_foreplay_text(role_graph, hard_item, composition, axis_values_text(axis_values)):
|
||||
return ""
|
||||
if is_outercourse_text(role_graph, hard_item, composition, axis_values_text(axis_values)):
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(position_text)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_GENERIC:
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(text)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB:
|
||||
return "breast-sex outercourse pose"
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE:
|
||||
return "testicle-sucking outercourse pose"
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING:
|
||||
return "penis-licking outercourse pose"
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB:
|
||||
return "handjob outercourse pose"
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB:
|
||||
return "footjob outercourse pose"
|
||||
return "non-penetrative outercourse pose"
|
||||
if is_toy_assisted_double_text(role_graph, hard_item, composition, axis_values_text(axis_values)):
|
||||
prefix = "toy-assisted " if ("toy" in text or "strap-on" in text) else ""
|
||||
front_back = "front-and-back" in text or "from the front" in text or "pussy and ass" in text
|
||||
if "face-down ass-up" in text or "face-down" in text:
|
||||
return f"{prefix}face-down rear-entry double-penetration pose"
|
||||
if "doggy style" in text or "doggy-style" in text or "all fours" in text or "rear-entry" in text:
|
||||
return f"{prefix}rear-entry double-penetration pose"
|
||||
if "bent-over" in text or "bent forward" in text:
|
||||
return f"{prefix}bent-over double-penetration pose"
|
||||
if "spooning anal" in text or "side-lying anal" in text or "side-lying" in text:
|
||||
return f"{prefix}side-lying double-penetration pose"
|
||||
if "edge-supported" in text or "bed-edge" in text or "edge-of-bed" in text:
|
||||
return f"{prefix}edge-supported front-and-back double-penetration pose" if front_back else f"{prefix}edge-supported double-penetration pose"
|
||||
if "standing anal" in text or "standing supported" in text or "standing" in text:
|
||||
return f"{prefix}standing front-and-back double-penetration pose" if front_back else f"{prefix}standing double-penetration pose"
|
||||
if "kneeling anal" in text or "kneeling" in text:
|
||||
return f"{prefix}kneeling front-and-back double-penetration pose" if front_back else f"{prefix}kneeling rear-entry double-penetration pose"
|
||||
return f"{prefix}front-and-back double-penetration pose" if front_back else f"{prefix}rear-entry double-penetration pose"
|
||||
if "double penetration" in text or "vaginal and anal penetration" in text or "front-and-back" in text:
|
||||
if "face-down ass-up" in text:
|
||||
return "face-down rear-entry double-penetration pose"
|
||||
if "doggy style" in text or "doggy-style" in text:
|
||||
return "doggy-style double-penetration pose"
|
||||
if "bent-over" in text:
|
||||
return "bent-over double-penetration pose"
|
||||
if "spooning anal" in text or "side-lying anal" in text:
|
||||
return "side-lying double-penetration pose"
|
||||
if "bed-edge" in text or "edge-of-bed" in text:
|
||||
return "bed-edge front-and-back double-penetration pose"
|
||||
if "standing anal" in text or "standing supported" in text:
|
||||
return "standing supported front-and-back double-penetration pose"
|
||||
if "kneeling anal" in text:
|
||||
return "kneeling rear-entry double-penetration pose"
|
||||
if "standing supported" in text:
|
||||
return "standing supported front-and-back double-penetration pose"
|
||||
if "kneeling" in text:
|
||||
return "kneeling front-and-back double-penetration pose"
|
||||
return "front-and-back double-penetration pose"
|
||||
if "sixty-nine" in position_text or ("sixty-nine" in text and not position_text):
|
||||
return "sixty-nine oral pose"
|
||||
if "face-sitting" in position_text or ("face-sitting" in text and not position_text):
|
||||
return "face-sitting oral pose"
|
||||
if "side-lying oral" in position_text or (("side-lying oral position" in item_text or "side-lying oral" in text) and not position_text):
|
||||
return "side-lying oral pose"
|
||||
if (
|
||||
"edge-of-bed oral" in position_text
|
||||
or "edge-supported oral" in position_text
|
||||
or (("edge-of-bed oral position" in item_text or "edge-of-bed oral" in text or "edge-supported oral" in text) and not position_text)
|
||||
):
|
||||
return "edge-supported oral pose"
|
||||
if "standing oral" in position_text or (("standing oral position" in item_text or "standing oral" in text) and not position_text):
|
||||
return "standing oral pose"
|
||||
if "chair oral" in position_text or (("chair oral position" in item_text or "chair oral" in text) and not position_text):
|
||||
return "chair oral pose"
|
||||
if "kneeling oral" in position_text or (("kneeling oral position" in item_text or "kneeling oral" in text) and not position_text):
|
||||
return "kneeling oral pose"
|
||||
if "straddled oral" in position_text or (("straddled oral position" in item_text or "straddled oral" in text) and not position_text):
|
||||
return "straddled cunnilingus pose"
|
||||
if "reclining cunnilingus" in position_text or (("reclining cunnilingus position" in item_text or "reclining cunnilingus" in text) and not position_text):
|
||||
return "reclining cunnilingus pose"
|
||||
if "spread-leg oral" in position_text or (("spread-leg oral position" in item_text or "spread-leg oral" in text) and not position_text):
|
||||
return "spread-leg oral pose"
|
||||
if "cunnilingus" in text or "pussy licking" in text or "mouth on her pussy" in text:
|
||||
if "reclining" in text:
|
||||
return "reclining cunnilingus pose"
|
||||
if "straddled" in text:
|
||||
return "straddled cunnilingus pose"
|
||||
return "open-thigh cunnilingus pose"
|
||||
if "oral" in text or "blowjob" in text or "penis in her mouth" in text or "penis in mouth" in text:
|
||||
if "side-lying oral position" in item_text:
|
||||
return "side-lying oral pose"
|
||||
if "spread-leg oral position" in item_text:
|
||||
return "spread-leg oral pose"
|
||||
if "edge-of-bed oral position" in item_text:
|
||||
return "edge-supported oral pose"
|
||||
if "standing oral position" in item_text:
|
||||
return "standing oral pose"
|
||||
if "chair oral position" in item_text:
|
||||
return "chair oral pose"
|
||||
if "kneeling oral position" in item_text or "kneeling" in text:
|
||||
return "kneeling oral pose"
|
||||
if "standing" in text:
|
||||
return "standing oral pose"
|
||||
if "side-lying" in text:
|
||||
return "side-lying oral pose"
|
||||
if "edge-of-bed" in text or "bed-edge" in text:
|
||||
return "edge-supported oral pose"
|
||||
if "spread-leg" in text:
|
||||
return "spread-leg oral pose"
|
||||
if "chair oral" in text:
|
||||
return "chair oral pose"
|
||||
return "mouth-to-genitals oral pose"
|
||||
if "anal" in text or mentions_rear_entry(text) or "rear-entry" in text:
|
||||
if "face-down ass-up" in text:
|
||||
return "face-down ass-up rear-entry anal pose"
|
||||
if "doggy style" in text or "doggy-style" in text:
|
||||
return "doggy-style anal pose"
|
||||
if "bed-edge" in text or "edge-of-bed" in text:
|
||||
return "bed-edge rear-entry anal pose"
|
||||
if "bent-over" in text:
|
||||
return "bent-over rear-entry anal pose"
|
||||
if "spooning anal" in text or "side-lying anal" in text:
|
||||
return "side-lying rear-entry anal pose"
|
||||
if "kneeling anal" in text:
|
||||
return "kneeling rear-entry anal pose"
|
||||
if "standing anal" in text:
|
||||
return "standing rear-entry anal pose"
|
||||
if "doggy" in text:
|
||||
return "doggy-style anal pose"
|
||||
return "rear-entry anal pose"
|
||||
if "edge-supported" in text or "raised edge" in text or "edge-of-bed" in text or "bed-edge" in text:
|
||||
return "edge-supported penetrative sex pose"
|
||||
positions = (
|
||||
"missionary",
|
||||
"reverse cowgirl",
|
||||
"cowgirl",
|
||||
"doggy style",
|
||||
"standing sex",
|
||||
"spooning sex",
|
||||
"edge-of-bed",
|
||||
"kneeling straddle",
|
||||
"lotus",
|
||||
"bent-over",
|
||||
)
|
||||
for position in positions:
|
||||
if position in text:
|
||||
return f"{position.replace('doggy style', 'doggy-style')} pose"
|
||||
if "threesome" in text or "three-body" in text:
|
||||
return "three-body explicit sex pose"
|
||||
if "group" in text or "orgy" in text:
|
||||
return "multi-body explicit sex pose"
|
||||
if re.search(r"(?<!non-)penetrat|thrust", text):
|
||||
return "hip-aligned penetrative sex pose"
|
||||
return ""
|
||||
|
||||
|
||||
def hardcore_pose_arrangement(anchor: str, role_graph: str, hard_item: str, composition: str = "", axis_values: Any = None) -> str:
|
||||
text = position_context_text(anchor, f"{role_graph} {hard_item}", composition, axis_values)
|
||||
position_text = ""
|
||||
if isinstance(axis_values, dict):
|
||||
position_text = _clean(axis_values.get("position", "")).lower()
|
||||
if not text:
|
||||
return ""
|
||||
mixed_woman_man = "the woman" in text and "the man" in text
|
||||
is_double = "double-penetration" in text or "double penetration" in text
|
||||
|
||||
def cast_phrase(mixed: str, generic: str) -> str:
|
||||
return mixed if mixed_woman_man else generic
|
||||
|
||||
def double_tail() -> str:
|
||||
return "" if "toy" in text else ", with the second penetration point aligned"
|
||||
|
||||
if "sixty-nine" in position_text or ("sixty-nine" in text and not position_text):
|
||||
return cast_phrase(
|
||||
"with the woman and man inverted head-to-hips so both mouths align with genitals",
|
||||
"with both bodies inverted head-to-hips so both mouths align with genitals",
|
||||
)
|
||||
if "face-sitting" in position_text or ("face-sitting" in text and not position_text):
|
||||
return cast_phrase(
|
||||
"with the man lying back while the woman straddles his face",
|
||||
"with one partner lying back while the other straddles the face",
|
||||
)
|
||||
if (
|
||||
"reclining cunnilingus" in position_text
|
||||
or "spread-leg oral" in position_text
|
||||
or (("reclining cunnilingus" in text or "spread-leg oral" in text) and not position_text)
|
||||
):
|
||||
if "takes the man's penis" in text or "penis in her mouth" in text:
|
||||
return cast_phrase(
|
||||
"with the man seated with legs apart and the woman positioned at his hips",
|
||||
"with the receiver seated with legs apart and the giver positioned at the hips",
|
||||
)
|
||||
return cast_phrase(
|
||||
"with the woman lying back, thighs spread, and the man positioned between her legs",
|
||||
"with the receiving partner lying back, thighs spread, and the giver positioned between the legs",
|
||||
)
|
||||
if (
|
||||
"straddled oral" in position_text
|
||||
or (("straddled cunnilingus" in text or "straddled oral" in text) and not position_text)
|
||||
):
|
||||
return cast_phrase(
|
||||
"with the woman straddling above the man's mouth and her thighs framing his face",
|
||||
"with the receiver straddling above the giver's mouth",
|
||||
)
|
||||
if (
|
||||
"edge-of-bed oral" in position_text
|
||||
or "edge-supported oral" in position_text
|
||||
or ("edge-of-bed oral" in text and not position_text)
|
||||
or ("edge-supported oral" in text and not position_text)
|
||||
):
|
||||
if "takes the man's penis" in text or "penis in her mouth" in text:
|
||||
return cast_phrase(
|
||||
"with the man at a raised edge and the woman kneeling at his hips",
|
||||
"with the receiver at a raised edge and the giver positioned at hip height",
|
||||
)
|
||||
return cast_phrase(
|
||||
"with the woman lying at a raised edge and the man positioned between her open thighs",
|
||||
"with the receiver lying at a raised edge and the giver positioned between open thighs",
|
||||
)
|
||||
if "standing oral" in position_text or ("standing oral" in text and not position_text):
|
||||
if "takes the man's penis" in text or "penis in her mouth" in text:
|
||||
return cast_phrase(
|
||||
"with the man standing and the woman kneeling in front of his hips",
|
||||
"with the receiver standing and the giver kneeling at hip height",
|
||||
)
|
||||
return cast_phrase(
|
||||
"with the woman standing braced and the man kneeling between her thighs",
|
||||
"with the receiver standing braced and the giver kneeling between the thighs",
|
||||
)
|
||||
if "chair oral" in position_text or ("chair oral" in text and not position_text):
|
||||
if "takes the man's penis" in text or "penis in her mouth" in text:
|
||||
return cast_phrase(
|
||||
"with the man seated in the chair and the woman kneeling between his legs at hip level",
|
||||
"with the receiver seated in the chair and the giver kneeling between the legs at hip level",
|
||||
)
|
||||
return cast_phrase(
|
||||
"with one partner seated in a chair and the other kneeling between the open thighs",
|
||||
"with the receiver seated in a chair and the giver kneeling between the open thighs",
|
||||
)
|
||||
if "side-lying oral" in position_text or ("side-lying oral" in text and not position_text):
|
||||
return "with both bodies lying on their sides and mouth aligned to genitals"
|
||||
if "kneeling oral" in position_text or ("kneeling oral" in text and not position_text):
|
||||
if "takes the man's penis" in text or "penis in her mouth" in text:
|
||||
return cast_phrase(
|
||||
"with the woman kneeling in front of the man's hips, her mouth at penis level",
|
||||
"with the giver kneeling in front of the receiver's hips",
|
||||
)
|
||||
if "mouth on her pussy" in text or "uses his mouth on" in text:
|
||||
return cast_phrase(
|
||||
"with the man kneeling between the woman's open thighs, his mouth at her pussy",
|
||||
"with the giver kneeling between the receiver's open thighs",
|
||||
)
|
||||
return "with the giver kneeling at the receiver's hips"
|
||||
if "reverse cowgirl" in text:
|
||||
return cast_phrase(
|
||||
"with the man lying on his back under the woman while she straddles his hips facing away",
|
||||
"with the lower partner lying on their back while the upper partner straddles them facing away",
|
||||
)
|
||||
if "cowgirl" in text:
|
||||
return cast_phrase(
|
||||
"with the man lying on his back under the woman while she straddles his hips on top",
|
||||
"with the lower partner lying on their back while the upper partner straddles their hips on top",
|
||||
)
|
||||
if "missionary" in text:
|
||||
return cast_phrase(
|
||||
"with the woman lying on her back under the man, legs open around his hips",
|
||||
"with the receiving partner lying on their back under the penetrating partner, legs open around the hips",
|
||||
)
|
||||
if "lotus" in text:
|
||||
return cast_phrase(
|
||||
"with the man seated upright and the woman seated in his lap facing him, legs wrapped around his hips",
|
||||
"with one partner seated upright and the other seated in their lap facing them, legs wrapped around the hips",
|
||||
)
|
||||
if "kneeling straddle" in text:
|
||||
return cast_phrase(
|
||||
"with the woman straddling the man's kneeling lap, both torsos upright and hips pressed together",
|
||||
"with one partner straddling the other's kneeling lap, torsos upright and hips pressed together",
|
||||
)
|
||||
if "doggy-style" in text:
|
||||
return cast_phrase(
|
||||
f"with the woman on all fours and the man positioned behind her at hip level{double_tail() if is_double else ''}",
|
||||
f"with the receiving partner on all fours and the penetrating partner positioned behind at hip level{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "face-down" in text:
|
||||
return cast_phrase(
|
||||
f"with the woman face-down, hips raised, and the man positioned behind her{double_tail() if is_double else ''}",
|
||||
f"with the receiving partner face-down, hips raised, and the penetrating partner positioned behind{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "bent-over" in text:
|
||||
return cast_phrase(
|
||||
f"with the woman bent forward at the waist and the man positioned behind her{double_tail() if is_double else ''}",
|
||||
f"with the receiving partner bent forward at the waist and the penetrating partner positioned behind{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "spooning" in text or ("side-lying" in text and "oral" not in text):
|
||||
return cast_phrase(
|
||||
f"with both lying on their sides and the man positioned behind the woman{double_tail() if is_double else ''}",
|
||||
f"with both bodies lying on their sides and the penetrating partner positioned behind{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "edge-of-bed" in text or "bed-edge" in text:
|
||||
return cast_phrase(
|
||||
f"with the woman lying at the bed edge, hips at the edge, and the man kneeling between her legs{double_tail() if is_double else ''}",
|
||||
f"with the receiver lying at the bed edge, hips at the edge, and the penetrating partner kneeling between the legs{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "standing" in text:
|
||||
return cast_phrase(
|
||||
f"with the woman braced standing and the man aligned at her hips{double_tail() if is_double else ''}",
|
||||
f"with both partners standing and the penetrating partner aligned at the receiver's hips{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "kneeling" in text and ("anal" in text or "rear-entry" in text):
|
||||
return cast_phrase(
|
||||
f"with the woman kneeling forward and the man positioned behind her{double_tail() if is_double else ''}",
|
||||
f"with the receiving partner kneeling forward and the penetrating partner positioned behind{double_tail() if is_double else ''}",
|
||||
)
|
||||
if "double-penetration" in text or "double penetration" in text:
|
||||
if "toy" in text:
|
||||
return cast_phrase(
|
||||
"with the woman on all fours and the man positioned behind her at hip level",
|
||||
"with the receiving body on all fours and the penetrating partner positioned behind at hip level",
|
||||
)
|
||||
if "from the front" in text:
|
||||
return cast_phrase(
|
||||
"with the woman held between the man behind her and a second partner in front",
|
||||
"with the receiving body held between one partner behind and a second partner in front",
|
||||
)
|
||||
return cast_phrase(
|
||||
"with the woman held in a front-and-back position so both contact points are visible",
|
||||
"with the central body held in a front-and-back position so both contact points are visible",
|
||||
)
|
||||
if "anal" in text or mentions_rear_entry(text) or "rear-entry" in text:
|
||||
return cast_phrase(
|
||||
"with the woman's hips raised, ass exposed, and the man positioned behind her",
|
||||
"with the receiving partner's hips raised and the penetrating partner positioned behind",
|
||||
)
|
||||
if "cunnilingus" in text or "mouth on her pussy" in text or "pussy licking" in text:
|
||||
return cast_phrase(
|
||||
"with the woman's thighs open and the man's mouth pressed to her pussy",
|
||||
"with the receiver's thighs open and the giver's mouth pressed to genitals",
|
||||
)
|
||||
if "oral" in text or "blowjob" in text or "penis in her mouth" in text or "penis in mouth" in text:
|
||||
if "takes the man's penis in her mouth" in text or "penis in her mouth" in text:
|
||||
return cast_phrase(
|
||||
"with the woman's mouth at the man's hips",
|
||||
"with the giver's mouth positioned at the receiver's hips",
|
||||
)
|
||||
return "with mouth and genitals aligned clearly"
|
||||
if "threesome" in text or "three-body" in text:
|
||||
return "with all three adult bodies clearly placed around the central subject"
|
||||
if "group" in text or "orgy" in text:
|
||||
return "with each adult body readable in the shared sex act"
|
||||
if re.search(r"(?<!non-)penetrat|thrust", text):
|
||||
return "with hips aligned and legs open around the contact point"
|
||||
return ""
|
||||
|
||||
|
||||
def arrangement_duplicates_role(arrangement: str, role_graph: str) -> bool:
|
||||
arrangement_lower = _clean(arrangement).lower()
|
||||
role_lower = _clean(role_graph).lower()
|
||||
if not arrangement_lower or not role_lower:
|
||||
return False
|
||||
markers = (
|
||||
"bed edge",
|
||||
"on all fours",
|
||||
"face-down",
|
||||
"hips raised",
|
||||
"bent forward",
|
||||
"straddl",
|
||||
"on her back",
|
||||
"on their sides",
|
||||
"on her side",
|
||||
"seated in",
|
||||
"sits in",
|
||||
"lap",
|
||||
"kneeling between",
|
||||
"kneels between",
|
||||
"kneeling in front",
|
||||
"kneels in front",
|
||||
"positioned behind",
|
||||
"standing",
|
||||
)
|
||||
return any(marker in arrangement_lower and marker in role_lower for marker in markers)
|
||||
|
||||
|
||||
def action_position_phrase(action: str) -> str:
|
||||
action = _clean(action).lower()
|
||||
if is_close_foreplay_text(action):
|
||||
return "single-frame close-body first-person position"
|
||||
if "pov reverse cowgirl" in action:
|
||||
return "reverse-cowgirl first-person position"
|
||||
if "pov cowgirl" in action:
|
||||
return "cowgirl first-person position"
|
||||
if "pov missionary" in action:
|
||||
return "missionary first-person position"
|
||||
if "pov raised-edge" in action or "raised edge" in action:
|
||||
return "raised-edge open-thigh position"
|
||||
if "pov doggy" in action or "on all fours" in action:
|
||||
return "all-fours rear-entry position"
|
||||
if "pov bent-over" in action or "bent forward" in action:
|
||||
return "bent-over rear-entry position"
|
||||
if "pov face-down" in action:
|
||||
return "face-down rear-entry position"
|
||||
if "pov standing" in action:
|
||||
return "standing rear-entry position"
|
||||
if "pov side-lying" in action:
|
||||
return "side-lying position"
|
||||
if "pov lotus" in action:
|
||||
return "lap-straddling position"
|
||||
if "face-down" in action and "ass raised" in action:
|
||||
return "face-down raised-hip position"
|
||||
if "on all fours" in action:
|
||||
return "all-fours raised-hip position"
|
||||
if "bends forward" in action or "bent forward" in action:
|
||||
return "bent-over raised-hip position"
|
||||
if "lies on her back" in action and ("thighs open" in action or "legs open" in action):
|
||||
return "open-thigh reclined position"
|
||||
if "lies at the bed edge" in action or "bed edge" in action:
|
||||
return "bed-edge position"
|
||||
if "lies on her side" in action:
|
||||
return "side-lying position"
|
||||
if "kneels in front" in action:
|
||||
return "kneeling-at-hip-height position"
|
||||
if "straddles" in action or "squats over" in action:
|
||||
return "straddling position"
|
||||
if "sits in the man's lap" in action:
|
||||
return "lap-straddling position"
|
||||
if "stands braced" in action:
|
||||
return "standing braced position"
|
||||
if "held between" in action or "front-and-back" in action:
|
||||
return "front-and-back position"
|
||||
if "lies between" in action:
|
||||
return "between-partners position"
|
||||
return ""
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .krea_action_positions import (
|
||||
arrangement_duplicates_role,
|
||||
hardcore_pose_arrangement,
|
||||
)
|
||||
from .krea_action_dispatch import resolve_hardcore_action_parts
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from krea_action_positions import (
|
||||
arrangement_duplicates_role,
|
||||
hardcore_pose_arrangement,
|
||||
)
|
||||
from krea_action_dispatch import resolve_hardcore_action_parts
|
||||
|
||||
|
||||
def _clean(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 _lowercase_for_inline_join(text: str) -> str:
|
||||
text = _clean(text)
|
||||
return text[:1].lower() + text[1:] if text else text
|
||||
|
||||
|
||||
def _with_indefinite_article(text: str) -> str:
|
||||
text = _clean(text)
|
||||
if not text or text.lower().startswith(("a ", "an ")):
|
||||
return text
|
||||
article = "an" if text[:1].lower() in "aeiou" else "a"
|
||||
return f"{article} {text}"
|
||||
|
||||
|
||||
def hardcore_action_sentence(
|
||||
role_graph: str,
|
||||
hard_item: str,
|
||||
composition: str = "",
|
||||
axis_values: Any = None,
|
||||
detail_density: str = "balanced",
|
||||
action_family: Any = "",
|
||||
) -> str:
|
||||
parts = resolve_hardcore_action_parts(role_graph, hard_item, composition, axis_values, detail_density, action_family)
|
||||
role_graph = parts.role_graph
|
||||
hard_item = parts.hard_item
|
||||
detail = parts.detail
|
||||
anchor = parts.anchor
|
||||
arrangement = hardcore_pose_arrangement(anchor, role_graph, hard_item, composition, axis_values)
|
||||
anchor_phrase = _with_indefinite_article(anchor) if anchor else ""
|
||||
if arrangement and anchor_phrase and not arrangement_duplicates_role(arrangement, role_graph):
|
||||
anchor_phrase = f"{anchor_phrase} {arrangement}"
|
||||
if role_graph and anchor_phrase:
|
||||
sentence = f"In {anchor_phrase}, {_lowercase_for_inline_join(role_graph)}"
|
||||
elif role_graph:
|
||||
sentence = role_graph
|
||||
elif detail and anchor_phrase:
|
||||
sentence = f"In {anchor_phrase}, {detail}"
|
||||
detail = ""
|
||||
else:
|
||||
sentence = detail or hard_item
|
||||
detail = ""
|
||||
if detail:
|
||||
sentence = f"{sentence}; {detail}"
|
||||
return sentence
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import formatter_input as input_policy
|
||||
except ImportError: # Allows local smoke tests with `python tools/prompt_smoke.py`.
|
||||
import formatter_input as input_policy
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return input_policy.clean_text(value)
|
||||
|
||||
|
||||
def _with_indefinite_article(text: str) -> str:
|
||||
text = _clean(text)
|
||||
if not text or text.lower().startswith(("a ", "an ")):
|
||||
return text
|
||||
article = "an" if text[:1].lower() in "aeiou" else "a"
|
||||
return f"{article} {text}"
|
||||
|
||||
|
||||
def prompt_cast_descriptors(text: str) -> str:
|
||||
return _clean(text).replace("Woman A / primary creator:", "Woman A:")
|
||||
|
||||
|
||||
def cast_entries(text: str) -> list[tuple[str, str]]:
|
||||
text = prompt_cast_descriptors(text)
|
||||
entries: list[tuple[str, str]] = []
|
||||
for part in text.split(";"):
|
||||
part = _clean(part)
|
||||
match = re.match(r"^((?:Woman|Man) [A-Z]):\s*(.+)$", part)
|
||||
if match:
|
||||
entries.append((match.group(1), _clean(match.group(2))))
|
||||
return entries
|
||||
|
||||
|
||||
def cast_labels(text: str) -> list[str]:
|
||||
return [label for label, _descriptor in cast_entries(text)]
|
||||
|
||||
|
||||
def natural_cast_descriptor_text(text: str) -> str:
|
||||
entries = cast_entries(text)
|
||||
if not entries:
|
||||
return _clean(text)
|
||||
labels = [label for label, _descriptor in entries]
|
||||
if labels == ["Woman A"] or labels == ["Man A"]:
|
||||
return f"A {entries[0][1]}"
|
||||
if set(labels) == {"Woman A", "Man A"} and len(labels) == 2:
|
||||
by_label = {label: descriptor for label, descriptor in entries}
|
||||
return f"A {by_label['Woman A']} alongside a {by_label['Man A']}"
|
||||
return " ".join(f"{label} is {descriptor}." for label, descriptor in entries)
|
||||
|
||||
|
||||
def label_join(labels: list[str]) -> str:
|
||||
labels = [_clean(label) for label in labels if _clean(label)]
|
||||
if not labels:
|
||||
return "the named adults"
|
||||
if set(labels) == {"Woman A", "Man A"}:
|
||||
return "the woman and man"
|
||||
if len(labels) == 1:
|
||||
if labels[0] == "Woman A":
|
||||
return "the woman"
|
||||
if labels[0] == "Man A":
|
||||
return "the man"
|
||||
return labels[0]
|
||||
if len(labels) == 2:
|
||||
return f"{labels[0]} and {labels[1]}"
|
||||
return f"{', '.join(labels[:-1])}, and {labels[-1]}"
|
||||
|
||||
|
||||
def natural_label_text(text: Any, labels: list[str], *, capitalize_sentence_starts: bool = True) -> str:
|
||||
text = _clean(text)
|
||||
if not text:
|
||||
return ""
|
||||
if set(labels) == {"Woman A", "Man A"}:
|
||||
text = re.sub(r"\bWoman A\b", "the woman", text)
|
||||
text = re.sub(r"\bMan A\b", "the man", text)
|
||||
elif labels == ["Woman A"]:
|
||||
text = re.sub(r"\bWoman A\b", "the woman", text)
|
||||
elif labels == ["Man A"]:
|
||||
text = re.sub(r"\bMan A\b", "the man", text)
|
||||
if capitalize_sentence_starts:
|
||||
text = re.sub(
|
||||
r"(^|[.!?]\s+)(the woman|the man)\b",
|
||||
lambda match: match.group(1) + match.group(2).capitalize(),
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def lowercase_for_inline_join(text: str) -> str:
|
||||
return re.sub(
|
||||
r"^(The woman|The man|The viewer|The named adults)\b",
|
||||
lambda match: match.group(1).lower(),
|
||||
_clean(text),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def cast_prose(
|
||||
text: str,
|
||||
central_label: str = "Woman A",
|
||||
omit_labels: list[str] | set[str] | tuple[str, ...] = (),
|
||||
) -> tuple[str, list[str]]:
|
||||
raw_entries = cast_entries(text)
|
||||
omitted = set(omit_labels or [])
|
||||
entries = [(label, descriptor) for label, descriptor in raw_entries if label not in omitted]
|
||||
if raw_entries and not entries:
|
||||
return "", []
|
||||
if not entries:
|
||||
return (f"{central_label} is {_clean(text)}" if _clean(text) else "", [])
|
||||
labels = [label for label, _descriptor in entries]
|
||||
if labels == ["Woman A"]:
|
||||
return _with_indefinite_article(entries[0][1]), labels
|
||||
if labels == ["Man A"]:
|
||||
return _with_indefinite_article(entries[0][1]), labels
|
||||
if set(labels) == {"Woman A", "Man A"} and len(labels) == 2:
|
||||
by_label = {label: descriptor for label, descriptor in entries}
|
||||
return f"{_with_indefinite_article(by_label['Woman A'])} alongside {_with_indefinite_article(by_label['Man A'])}", labels
|
||||
sentences = []
|
||||
for label, descriptor in entries:
|
||||
sentences.append(f"{label} is {descriptor}.")
|
||||
if central_label in labels:
|
||||
sentences.append(f"{central_label} is the central subject.")
|
||||
return " ".join(sentences), labels
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .krea_cast import natural_label_text
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from krea_cast import natural_label_text
|
||||
|
||||
|
||||
def _clean(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 clothing_access_phrase(action_text: Any) -> str:
|
||||
text = _clean(action_text).lower()
|
||||
if any(term in text for term in ("cumshot", "ejaculat", "semen", "cum on", "cum across", "post-orgasm", "aftermath")):
|
||||
return "leaving the body exposed for visible semen and aftermath"
|
||||
if any(term in text for term in ("boobjob", "titjob", "breast sex", "handjob", "hand job", "footjob", "testicle", "balls", "penis licking", "non-penetrative")):
|
||||
return "leaving the contact point unobstructed"
|
||||
if any(term in text for term in ("oral", "blowjob", "fellatio", "mouth", "tongue")):
|
||||
return "leaving the oral contact unobstructed"
|
||||
if any(term in text for term in ("penetrat", "thrust", "penis entering", "vaginal", "anal")):
|
||||
return "leaving the penetration point unobstructed"
|
||||
return "leaving skin and body contact readable"
|
||||
|
||||
|
||||
def natural_clothing_state(text: Any, action_text: Any = "") -> str:
|
||||
text = _clean(text)
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"^Clothing state:\s*", "", text, flags=re.IGNORECASE)
|
||||
if re.search(r";\s*(?=(?:Woman|Man) [A-Z]\b)", text):
|
||||
parts = [
|
||||
natural_clothing_state(part, action_text).rstrip(".")
|
||||
for part in re.split(r";\s*(?=(?:Woman|Man) [A-Z]\b)", text)
|
||||
if _clean(part)
|
||||
]
|
||||
return ". ".join(part for part in parts if part)
|
||||
body_exposure = re.match(r"^Body exposure:\s*(.*?)\.?$", text, flags=re.IGNORECASE)
|
||||
if body_exposure:
|
||||
return _clean(body_exposure.group(1)).rstrip(".")
|
||||
if re.search(r"\bfully nude\b|\bbody is fully exposed\b|\bno clothing covering\b", text, flags=re.IGNORECASE):
|
||||
owner = "the woman"
|
||||
owner_match = re.match(r"^\s*((?:Woman|Man) [A-Z])\b", text)
|
||||
if owner_match:
|
||||
owner = natural_label_text(owner_match.group(1), ["Woman A", "Man A"]) or owner
|
||||
return f"{owner.capitalize()}'s body is fully exposed, bare skin unobstructed"
|
||||
match = re.match(
|
||||
r"^(.*?)\b(?:softcore|teaser) outfit is (.*?)(?: for the (?:hardcore|sex) scene)?;\s*(?:softcore visual reference|teaser outfit detail):\s*(.*?)\.?$",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
owner = natural_label_text(match.group(1).strip(" 's"), ["Woman A", "Man A"]).strip() or "the woman"
|
||||
state = _clean(match.group(2)).lower()
|
||||
outfit = _clean(match.group(3)).rstrip(".")
|
||||
if "fully nude" in state or "fully exposed" in state or "no clothing covering" in state:
|
||||
return f"{owner.capitalize()}'s body is fully exposed, bare skin unobstructed"
|
||||
if "nude-adjacent" in state:
|
||||
return f"{owner.capitalize()}'s body is partly exposed"
|
||||
if "partially removed" in state or "pushed aside" in state:
|
||||
return f"{owner.capitalize()}'s {outfit} is pushed aside or partly removed where needed, {clothing_access_phrase(action_text)}"
|
||||
if "keeps" in state:
|
||||
return f"{owner.capitalize()} keeps the {outfit} on while {clothing_access_phrase(action_text)}"
|
||||
text = re.sub(r";\s*(?:softcore visual reference|teaser outfit detail):\s*", ". Visual clothing state: ", text, flags=re.IGNORECASE)
|
||||
text = text.replace("softcore outfit", "outfit")
|
||||
text = text.replace("teaser outfit", "outfit")
|
||||
text = text.replace("hardcore scene", "sex scene")
|
||||
return text
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaConfiguredCastRequest:
|
||||
row: dict[str, Any]
|
||||
detail_level: str
|
||||
style_mode: str
|
||||
primary: str
|
||||
item: str
|
||||
scene: str
|
||||
expression: str
|
||||
composition: str
|
||||
source_composition: str
|
||||
camera: str
|
||||
camera_scene: str
|
||||
style: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaConfiguredCastPrompt:
|
||||
prompt: str
|
||||
method: str = "metadata(configured_cast)"
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.prompt, self.method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaConfiguredCastDependencies:
|
||||
clean: Callable[[Any], str]
|
||||
sanitize_hardcore_environment_anchors: Callable[[Any], str]
|
||||
sanitize_hardcore_axis_values: Callable[[Any], Any]
|
||||
sanitize_scene_text_for_cast: Callable[[Any, list[str]], str]
|
||||
normalize_hardcore_detail_density: Callable[[Any], str]
|
||||
row_action_family: Callable[[Any], str]
|
||||
hardcore_action_sentence: Callable[[str, str, str, Any, str, str], str]
|
||||
pov_action_phrase: Callable[[str, list[str], str, str, str, Any, str], str]
|
||||
pov_labels_from_value: Callable[[Any], list[str]]
|
||||
merge_labels: Callable[..., list[str]]
|
||||
cast_prose_omit: Callable[[str, list[str]], tuple[str, list[str]]]
|
||||
filter_pov_labeled_clauses: Callable[[Any, list[str]], str]
|
||||
natural_label_text: Callable[[Any, list[str]], str]
|
||||
pov_composition_text: Callable[[Any, list[str]], str]
|
||||
pov_camera_phrase: Callable[[list[str]], str]
|
||||
expression_phrase: Callable[[Any], str]
|
||||
composition_phrase: Callable[..., str]
|
||||
paragraph: Callable[[list[str]], str]
|
||||
|
||||
|
||||
def format_configured_cast_result(
|
||||
request: KreaConfiguredCastRequest,
|
||||
deps: KreaConfiguredCastDependencies,
|
||||
) -> KreaConfiguredCastPrompt:
|
||||
row = request.row
|
||||
subject = deps.clean(row.get("subject_phrase") or request.primary or "adult sexual scene")
|
||||
cast = deps.clean(row.get("cast_summary"))
|
||||
try:
|
||||
women_count = int(row.get("women_count") or 0)
|
||||
men_count = int(row.get("men_count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
women_count = men_count = 0
|
||||
cast_descriptor_text = deps.clean(row.get("cast_descriptor_text"))
|
||||
pov_labels = deps.pov_labels_from_value(row.get("pov_character_labels"))
|
||||
camera = request.camera
|
||||
if pov_labels:
|
||||
camera = ""
|
||||
cast_prose, cast_labels = deps.cast_prose_omit(cast_descriptor_text, pov_labels)
|
||||
if not cast_labels and women_count == 1 and men_count == 1:
|
||||
cast_labels = ["Woman A", "Man A"]
|
||||
cast_labels = deps.merge_labels(cast_labels, pov_labels)
|
||||
expression = deps.filter_pov_labeled_clauses(request.expression, pov_labels)
|
||||
expression = deps.natural_label_text(expression, cast_labels)
|
||||
composition = deps.sanitize_hardcore_environment_anchors(request.composition)
|
||||
source_composition = deps.sanitize_hardcore_environment_anchors(request.source_composition)
|
||||
role_graph = deps.sanitize_scene_text_for_cast(
|
||||
deps.sanitize_hardcore_environment_anchors(row.get("source_role_graph") or row.get("role_graph")),
|
||||
cast_labels,
|
||||
)
|
||||
item = deps.sanitize_scene_text_for_cast(
|
||||
deps.sanitize_hardcore_environment_anchors(request.item),
|
||||
cast_labels,
|
||||
)
|
||||
role_graph = deps.natural_label_text(role_graph, cast_labels)
|
||||
item = deps.natural_label_text(item, cast_labels)
|
||||
axis_values = deps.sanitize_hardcore_axis_values(row.get("item_axis_values"))
|
||||
detail_density = deps.normalize_hardcore_detail_density(row.get("hardcore_detail_density"))
|
||||
action = deps.hardcore_action_sentence(
|
||||
role_graph,
|
||||
item,
|
||||
source_composition,
|
||||
axis_values,
|
||||
detail_density,
|
||||
deps.row_action_family(row),
|
||||
)
|
||||
action = deps.pov_action_phrase(
|
||||
action,
|
||||
pov_labels,
|
||||
role_graph,
|
||||
item,
|
||||
source_composition,
|
||||
axis_values,
|
||||
detail_density,
|
||||
)
|
||||
output_composition = deps.pov_composition_text(composition, pov_labels)
|
||||
parts = [
|
||||
action,
|
||||
deps.pov_camera_phrase(pov_labels),
|
||||
cast_prose,
|
||||
f"A consensual explicit adult scene with {subject}" if not action else "",
|
||||
f"The cast includes {cast}" if cast and not cast_prose and not (women_count == 1 and men_count == 1) else "",
|
||||
f"The setting is {request.scene}" if request.scene else "",
|
||||
request.camera_scene,
|
||||
deps.expression_phrase(expression),
|
||||
deps.composition_phrase(output_composition, action, "The image is framed as", detail_density),
|
||||
camera,
|
||||
request.style if request.detail_level != "concise" else "",
|
||||
]
|
||||
return KreaConfiguredCastPrompt(deps.paragraph(parts))
|
||||
|
||||
|
||||
def format_configured_cast(
|
||||
request: KreaConfiguredCastRequest,
|
||||
deps: KreaConfiguredCastDependencies,
|
||||
) -> tuple[str, str]:
|
||||
return format_configured_cast_result(request, deps).as_tuple()
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .krea_action_context import normalize_hardcore_detail_density
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
from krea_action_context import normalize_hardcore_detail_density
|
||||
|
||||
|
||||
def _clean(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 detail_clauses(detail: str) -> list[str]:
|
||||
return [part.strip(" ,;") for part in re.split(r",\s*(?:and\s+)?", _clean(detail)) if part.strip(" ,;")]
|
||||
|
||||
|
||||
def join_detail_clauses(clauses: list[str]) -> str:
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for clause in clauses:
|
||||
clause = _clean(clause).strip(" ,;")
|
||||
key = clause.lower()
|
||||
if clause and key not in seen:
|
||||
cleaned.append(clause)
|
||||
seen.add(key)
|
||||
return ", ".join(cleaned)
|
||||
|
||||
|
||||
def limit_detail_for_density(detail: str, density: str, is_climax: bool) -> str:
|
||||
density = normalize_hardcore_detail_density(density)
|
||||
if density == "compact":
|
||||
return ""
|
||||
clauses = detail_clauses(detail)
|
||||
if not clauses:
|
||||
return ""
|
||||
if density == "balanced":
|
||||
limit = 1 if is_climax else 2
|
||||
else:
|
||||
limit = 3 if is_climax else 4
|
||||
return join_detail_clauses(clauses[:limit])
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import formatter_detail as detail_policy
|
||||
from . import formatter_input as input_policy
|
||||
from . import formatter_route_trace as trace_policy
|
||||
from . import formatter_target as target_policy
|
||||
except ImportError: # pragma: no cover - plain-script smoke tests
|
||||
import formatter_detail as detail_policy
|
||||
import formatter_input as input_policy
|
||||
import formatter_route_trace as trace_policy
|
||||
import formatter_target as target_policy
|
||||
|
||||
|
||||
STYLE_MODES = ("preserve", "photographic", "minimal")
|
||||
DEFAULT_STYLE_MODE = "preserve"
|
||||
|
||||
|
||||
def style_mode_choices() -> list[str]:
|
||||
return list(STYLE_MODES)
|
||||
|
||||
|
||||
def normalize_style_mode(value: Any) -> str:
|
||||
mode = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
return mode if mode in STYLE_MODES else DEFAULT_STYLE_MODE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaFormatRequest:
|
||||
source_text: str
|
||||
metadata_json: str = ""
|
||||
negative_prompt: str = ""
|
||||
input_hint: str = "auto"
|
||||
target: str = "auto"
|
||||
detail_level: str = "balanced"
|
||||
style_mode: str = "preserve"
|
||||
preserve_trigger: bool = False
|
||||
extra_positive: str = ""
|
||||
extra_negative: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaFormatRoute:
|
||||
output: dict[str, str]
|
||||
branch: str
|
||||
method: str
|
||||
target: str
|
||||
detail_level: str
|
||||
style_mode: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaFormatDependencies:
|
||||
trigger_candidates: tuple[str, ...]
|
||||
clean: Callable[[Any], str]
|
||||
row_from_inputs: Callable[[str, str, str], tuple[dict[str, Any] | None, str]]
|
||||
normal_row_to_krea: Callable[[dict[str, Any], str, str], tuple[str, str]]
|
||||
insta_pair_to_krea: Callable[[dict[str, Any], str, str], tuple[str, str, str, str]]
|
||||
fallback_text_to_krea: Callable[[str, bool, str, str], tuple[str, str, str]]
|
||||
append_formatter_hints: Callable[..., str]
|
||||
combine_negative: Callable[..., str]
|
||||
sanitize_prose_text: Callable[..., str]
|
||||
sanitize_negative_text: Callable[[str], str]
|
||||
|
||||
|
||||
def format_krea2_prompt_result(request: KreaFormatRequest, deps: KreaFormatDependencies) -> KreaFormatRoute:
|
||||
detail_level = detail_policy.normalize_detail_level(request.detail_level)
|
||||
style_mode = normalize_style_mode(request.style_mode)
|
||||
target = target_policy.normalize_target(request.target)
|
||||
input_hint = input_policy.normalize_input_hint(request.input_hint, text_hint=input_policy.INPUT_HINT_PROMPT)
|
||||
row, method = deps.row_from_inputs(request.source_text, request.metadata_json, request.input_hint)
|
||||
|
||||
if row and input_policy.is_pair_metadata(row):
|
||||
pair_target = target_policy.pair_policy(target)
|
||||
soft_prompt, soft_negative, hard_prompt, hard_negative = deps.insta_pair_to_krea(
|
||||
row,
|
||||
detail_level,
|
||||
style_mode,
|
||||
)
|
||||
soft_row = row.get("softcore_row") if isinstance(row.get("softcore_row"), dict) else {}
|
||||
hard_row = row.get("hardcore_row") if isinstance(row.get("hardcore_row"), dict) else {}
|
||||
soft_prompt = deps.append_formatter_hints(soft_prompt, row, soft_row)
|
||||
hard_prompt = deps.append_formatter_hints(hard_prompt, row, hard_row)
|
||||
if request.extra_positive.strip():
|
||||
soft_prompt = f"{soft_prompt.rstrip()} {request.extra_positive.strip()}"
|
||||
hard_prompt = f"{hard_prompt.rstrip()} {request.extra_positive.strip()}"
|
||||
soft_prompt = deps.sanitize_prose_text(soft_prompt, triggers=deps.trigger_candidates)
|
||||
hard_prompt = deps.sanitize_prose_text(hard_prompt, triggers=deps.trigger_candidates)
|
||||
selected = hard_prompt if pair_target.selected_side == "hardcore" else soft_prompt
|
||||
selected_negative = hard_negative if pair_target.selected_side == "hardcore" else soft_negative
|
||||
negative = deps.sanitize_negative_text(
|
||||
deps.combine_negative(selected_negative, request.negative_prompt, request.extra_negative)
|
||||
)
|
||||
output = {
|
||||
"krea_prompt": selected,
|
||||
"negative_prompt": negative,
|
||||
"krea_softcore_prompt": soft_prompt,
|
||||
"krea_hardcore_prompt": hard_prompt,
|
||||
"softcore_negative_prompt": deps.sanitize_negative_text(
|
||||
deps.combine_negative(soft_negative, request.extra_negative)
|
||||
),
|
||||
"hardcore_negative_prompt": deps.sanitize_negative_text(
|
||||
deps.combine_negative(hard_negative, request.extra_negative)
|
||||
),
|
||||
"method": f"{method}:krea2(insta_of_pair)",
|
||||
}
|
||||
output["route_trace_json"] = trace_policy.route_trace_json(
|
||||
formatter="krea2",
|
||||
branch="insta_of_pair",
|
||||
method=output["method"],
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_mode=style_mode,
|
||||
**trace_policy.metadata_trace_fields(row, target=target, selected_side=pair_target.selected_side),
|
||||
)
|
||||
return KreaFormatRoute(
|
||||
output=output,
|
||||
branch="insta_of_pair",
|
||||
method=output["method"],
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_mode=style_mode,
|
||||
)
|
||||
|
||||
if row:
|
||||
prompt, kind = deps.normal_row_to_krea(row, detail_level, style_mode)
|
||||
prompt = deps.append_formatter_hints(prompt, row)
|
||||
extracted_negative = deps.clean(row.get("negative_prompt"))
|
||||
method = f"{method}:krea2({kind})"
|
||||
branch = kind
|
||||
else:
|
||||
prompt, extracted_negative, method = deps.fallback_text_to_krea(
|
||||
request.source_text,
|
||||
request.preserve_trigger,
|
||||
detail_level,
|
||||
style_mode,
|
||||
)
|
||||
branch = "fallback"
|
||||
|
||||
if request.extra_positive.strip():
|
||||
prompt = f"{prompt.rstrip()} {request.extra_positive.strip()}"
|
||||
prompt = deps.sanitize_prose_text(prompt, triggers=deps.trigger_candidates)
|
||||
negative = deps.sanitize_negative_text(
|
||||
deps.combine_negative(extracted_negative, request.negative_prompt, request.extra_negative)
|
||||
)
|
||||
output = {
|
||||
"krea_prompt": prompt,
|
||||
"negative_prompt": negative,
|
||||
"krea_softcore_prompt": "",
|
||||
"krea_hardcore_prompt": "",
|
||||
"softcore_negative_prompt": "",
|
||||
"hardcore_negative_prompt": "",
|
||||
"method": method,
|
||||
}
|
||||
output["route_trace_json"] = trace_policy.route_trace_json(
|
||||
formatter="krea2",
|
||||
branch=branch,
|
||||
method=method,
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_mode=style_mode,
|
||||
**trace_policy.metadata_trace_fields(row, target=target),
|
||||
)
|
||||
return KreaFormatRoute(
|
||||
output=output,
|
||||
branch=branch,
|
||||
method=method,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_mode=style_mode,
|
||||
)
|
||||
|
||||
|
||||
def format_krea2_prompt(request: KreaFormatRequest, deps: KreaFormatDependencies) -> dict[str, str]:
|
||||
return format_krea2_prompt_result(request, deps).output
|
||||
+329
-2381
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaNormalRowRequest:
|
||||
row: dict[str, Any]
|
||||
detail_level: str
|
||||
style_mode: str
|
||||
subject_type: str
|
||||
primary: str
|
||||
item: str
|
||||
scene: str
|
||||
pose: str
|
||||
expression: str
|
||||
composition: str
|
||||
camera: str
|
||||
camera_scene: str
|
||||
style: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaNormalRowPrompt:
|
||||
prompt: str
|
||||
method: str
|
||||
|
||||
def as_tuple(self) -> tuple[str, str]:
|
||||
return self.prompt, self.method
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaNormalRowDependencies:
|
||||
clean: Callable[[Any], str]
|
||||
row_value: Callable[[dict[str, Any], str, tuple[str, ...]], str]
|
||||
age_subject: Callable[[dict[str, Any], str], str]
|
||||
age_detail_phrase: Callable[[Any], str]
|
||||
appearance_phrase: Callable[[dict[str, Any]], str]
|
||||
with_indefinite_article: Callable[[str], str]
|
||||
paragraph: Callable[[list[str]], str]
|
||||
|
||||
|
||||
def _couple_clothing_phrase(item: str, clean: Callable[[Any], str]) -> str:
|
||||
item = clean(item)
|
||||
lower = item.lower()
|
||||
partner_text = re.sub(r"\bPartner ([AB]) wears\b", r"Partner \1 wearing", item)
|
||||
partner_text = re.sub(r"\bPartner ([AB]) has\b", r"Partner \1 with", partner_text)
|
||||
if lower.startswith("partner a "):
|
||||
return f"The outfits show {partner_text}"
|
||||
if lower.startswith(("two ", "paired ", "coordinated ")):
|
||||
return f"The outfits are {partner_text}"
|
||||
return f"The couple wears {item}"
|
||||
|
||||
|
||||
def _cap_first(text: str) -> str:
|
||||
text = str(text or "").strip()
|
||||
return f"{text[:1].upper()}{text[1:]}" if text else ""
|
||||
|
||||
|
||||
def _couple_subject_phrase(subject: str, ages: str) -> str:
|
||||
subject = _cap_first(subject or "adult couple")
|
||||
ages = str(ages or "").strip()
|
||||
if ages:
|
||||
return f"{subject}, {ages}"
|
||||
return subject
|
||||
|
||||
|
||||
def _framed_composition_phrase(composition: str, prefix: str = "framed as") -> str:
|
||||
composition = re.sub(r"\s+composition$", "", str(composition or "").strip(), flags=re.IGNORECASE)
|
||||
composition = re.sub(
|
||||
r"\bcomposition\b",
|
||||
"frame",
|
||||
composition,
|
||||
flags=re.IGNORECASE,
|
||||
).strip(" ,")
|
||||
if not composition:
|
||||
return ""
|
||||
return f"{prefix} {composition}"
|
||||
|
||||
|
||||
def _appearance_with_phrase(appearance: str, with_indefinite_article: Callable[[str], str]) -> str:
|
||||
appearance = str(appearance or "").strip()
|
||||
if not appearance:
|
||||
return ""
|
||||
first_clause = appearance.split(",", 1)[0].lower()
|
||||
if re.search(r"\b(?:body|build|figure|frame|physique|silhouette)\b", first_clause):
|
||||
nested_shape = re.match(
|
||||
r"^(.+\b(?:body|build|figure|frame|physique|silhouette))\s+with\s+(.+?)(?=,\s+[^,]*(?:skin|hair|eyes)\b|$)(.*)$",
|
||||
appearance,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if nested_shape:
|
||||
shape = with_indefinite_article(nested_shape.group(1).strip())
|
||||
detail = nested_shape.group(2).strip()
|
||||
rest = nested_shape.group(3).strip()
|
||||
return f"with {shape} defined by {detail}{rest}"
|
||||
appearance = with_indefinite_article(appearance)
|
||||
return f"with {appearance}"
|
||||
|
||||
|
||||
def format_normal_row_result(
|
||||
request: KreaNormalRowRequest,
|
||||
deps: KreaNormalRowDependencies,
|
||||
) -> KreaNormalRowPrompt:
|
||||
row = request.row
|
||||
subject_type = request.subject_type
|
||||
primary = request.primary
|
||||
item = request.item
|
||||
scene = request.scene
|
||||
pose = request.pose
|
||||
expression = request.expression
|
||||
composition = request.composition
|
||||
camera = request.camera
|
||||
camera_scene = request.camera_scene
|
||||
style = request.style
|
||||
detail_level = request.detail_level
|
||||
|
||||
if primary in ("woman", "man") or subject_type in ("woman", "man", "single_any"):
|
||||
subject = deps.age_subject(row, "adult woman")
|
||||
appearance = deps.appearance_phrase(row)
|
||||
subject_phrase = deps.with_indefinite_article(subject)
|
||||
appearance_phrase = _appearance_with_phrase(appearance, deps.with_indefinite_article)
|
||||
if appearance_phrase:
|
||||
subject_phrase = f"{subject_phrase} {appearance_phrase}"
|
||||
parts = [
|
||||
subject_phrase,
|
||||
f"wearing {item}" if item else "",
|
||||
f"{pose}" if pose else "",
|
||||
f"with {expression}" if expression else "",
|
||||
f"in {scene}" if scene else "",
|
||||
camera_scene,
|
||||
_framed_composition_phrase(composition),
|
||||
camera,
|
||||
style if detail_level != "concise" else "",
|
||||
]
|
||||
return KreaNormalRowPrompt(
|
||||
deps.paragraph([", ".join(part for part in parts[:5] if part), *parts[5:]]),
|
||||
"metadata(single)",
|
||||
)
|
||||
|
||||
if subject_type == "couple" or primary in ("two women", "two men", "a woman and a man"):
|
||||
subject = deps.clean(row.get("subject_phrase") or primary or "adult couple")
|
||||
if subject == "woman and man":
|
||||
subject = "a woman and a man"
|
||||
ages = deps.age_detail_phrase(deps.row_value(row, "age", ("Ages",)) or row.get("age_band"))
|
||||
body = deps.row_value(row, "body", ("Body types",)) or deps.clean(row.get("body_type"))
|
||||
parts = [
|
||||
_couple_subject_phrase(subject, ages),
|
||||
f"Body types: {body}" if body else "",
|
||||
_couple_clothing_phrase(item, deps.clean) if item else "",
|
||||
f"The pose is {pose}" if pose else "",
|
||||
f"The setting is {scene}" if scene else "",
|
||||
camera_scene,
|
||||
f"Facial expressions are {expression}" if expression else "",
|
||||
_framed_composition_phrase(composition, "The image is framed as"),
|
||||
camera,
|
||||
style if detail_level != "concise" else "",
|
||||
]
|
||||
return KreaNormalRowPrompt(deps.paragraph(parts), "metadata(couple)")
|
||||
|
||||
subject = deps.age_subject(row, primary or "adult scene")
|
||||
parts = [
|
||||
f"{subject}",
|
||||
f"featuring {item}" if item else "",
|
||||
f"in {scene}" if scene else "",
|
||||
camera_scene,
|
||||
f"with {expression}" if expression else "",
|
||||
_framed_composition_phrase(composition),
|
||||
camera,
|
||||
style if detail_level != "concise" else "",
|
||||
]
|
||||
return KreaNormalRowPrompt(deps.paragraph(parts), "metadata(generic)")
|
||||
|
||||
|
||||
def format_normal_row(
|
||||
request: KreaNormalRowRequest,
|
||||
deps: KreaNormalRowDependencies,
|
||||
) -> tuple[str, str]:
|
||||
return format_normal_row_result(request, deps).as_tuple()
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaPairFormatRequest:
|
||||
row: dict[str, Any]
|
||||
detail_level: str
|
||||
style_mode: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaPairPrompts:
|
||||
soft_prompt: str
|
||||
soft_negative: str
|
||||
hard_prompt: str
|
||||
hard_negative: str
|
||||
|
||||
def as_tuple(self) -> tuple[str, str, str, str]:
|
||||
return self.soft_prompt, self.soft_negative, self.hard_prompt, self.hard_negative
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaPairFormatDependencies:
|
||||
clean: Callable[[Any], str]
|
||||
prompt_cast_descriptors: Callable[[str], str]
|
||||
pair_camera_phrase: Callable[[Any, Any, dict[str, Any]], str]
|
||||
camera_scene_phrase: Callable[[dict[str, Any]], str]
|
||||
style_phrase: Callable[[dict[str, Any], str], str]
|
||||
sanitize_hardcore_environment_anchors: Callable[[Any], str]
|
||||
sanitize_hardcore_axis_values: Callable[[Any], Any]
|
||||
sanitize_scene_text_for_cast: Callable[[Any, list[str]], str]
|
||||
normalize_hardcore_detail_density: Callable[[Any], str]
|
||||
row_action_family: Callable[[Any], str]
|
||||
hardcore_action_sentence: Callable[[str, str, str, Any, str, str], str]
|
||||
pov_action_phrase: Callable[[str, list[str], str, str, str, Any, str], str]
|
||||
pov_labels_from_value: Callable[[Any], list[str]]
|
||||
merge_labels: Callable[..., list[str]]
|
||||
cast_prose_omit: Callable[[str, list[str]], tuple[str, list[str]]]
|
||||
label_join: Callable[[list[str]], str]
|
||||
filter_pov_labeled_clauses: Callable[[Any, list[str]], str]
|
||||
natural_label_text: Callable[[Any, list[str]], str]
|
||||
expression_disabled: Callable[[dict[str, Any]], bool]
|
||||
expression_phrase: Callable[[Any], str]
|
||||
pov_camera_phrase: Callable[[list[str]], str]
|
||||
pov_soft_camera_phrase: Callable[[list[str]], str]
|
||||
pov_composition_text: Callable[[Any, list[str]], str]
|
||||
softcore_cast_presence_phrase: Callable[..., str]
|
||||
natural_clothing_state: Callable[[Any, str], str]
|
||||
composition_phrase: Callable[..., str]
|
||||
paragraph: Callable[[list[str]], str]
|
||||
combine_negative: Callable[..., str]
|
||||
|
||||
|
||||
def format_insta_pair_result(request: KreaPairFormatRequest, deps: KreaPairFormatDependencies) -> KreaPairPrompts:
|
||||
row = request.row
|
||||
detail_level = request.detail_level
|
||||
style_mode = request.style_mode
|
||||
descriptor = deps.clean(row.get("shared_descriptor"))
|
||||
cast_descriptors = row.get("shared_cast_descriptors")
|
||||
if isinstance(cast_descriptors, list):
|
||||
cast_descriptor_text = "; ".join(deps.clean(item) for item in cast_descriptors if deps.clean(item))
|
||||
else:
|
||||
cast_descriptor_text = deps.clean(cast_descriptors)
|
||||
cast_descriptor_text = deps.prompt_cast_descriptors(cast_descriptor_text)
|
||||
soft = row.get("softcore_row") if isinstance(row.get("softcore_row"), dict) else {}
|
||||
hard = row.get("hardcore_row") if isinstance(row.get("hardcore_row"), dict) else {}
|
||||
soft_camera = deps.pair_camera_phrase(row.get("softcore_camera_directive"), row.get("softcore_camera_config"), soft)
|
||||
hard_camera = deps.pair_camera_phrase(row.get("hardcore_camera_directive"), row.get("hardcore_camera_config"), hard)
|
||||
soft_camera_scene = deps.camera_scene_phrase(soft) or deps.clean(row.get("softcore_camera_scene_directive"))
|
||||
hard_camera_scene = deps.camera_scene_phrase(hard) or deps.clean(row.get("hardcore_camera_scene_directive"))
|
||||
soft_style = deps.style_phrase(soft, style_mode)
|
||||
hard_style = deps.style_phrase(hard, style_mode)
|
||||
options = row.get("options") if isinstance(row.get("options"), dict) else {}
|
||||
soft_level = deps.clean(options.get("softcore_level")).replace("_", " ")
|
||||
hard_level = deps.clean(options.get("hardcore_level")).replace("_", " ")
|
||||
same_room = options.get("continuity") == "same_creator_same_room"
|
||||
hard_scene = hard.get("scene_text") or (soft.get("scene_text") if same_room else "")
|
||||
hard_composition = deps.sanitize_hardcore_environment_anchors(hard.get("composition"))
|
||||
hard_source_composition = deps.sanitize_hardcore_environment_anchors(hard.get("source_composition") or hard_composition)
|
||||
pov_labels = deps.merge_labels(
|
||||
deps.pov_labels_from_value(row.get("pov_character_labels")),
|
||||
deps.pov_labels_from_value(soft.get("pov_character_labels")),
|
||||
deps.pov_labels_from_value(hard.get("pov_character_labels")),
|
||||
)
|
||||
if pov_labels:
|
||||
hard_camera = ""
|
||||
if options.get("softcore_cast") == "same_as_hardcore":
|
||||
soft_camera = ""
|
||||
soft_cast_descriptor_text = (
|
||||
cast_descriptor_text
|
||||
if options.get("softcore_cast") == "same_as_hardcore"
|
||||
else f"Woman A: {descriptor}"
|
||||
)
|
||||
soft_cast_prose, soft_labels = deps.cast_prose_omit(
|
||||
soft_cast_descriptor_text,
|
||||
pov_labels if options.get("softcore_cast") == "same_as_hardcore" else [],
|
||||
)
|
||||
hard_cast_prose, hard_labels = deps.cast_prose_omit(cast_descriptor_text, pov_labels)
|
||||
soft_labels = deps.merge_labels(soft_labels, pov_labels if options.get("softcore_cast") == "same_as_hardcore" else [])
|
||||
hard_labels = deps.merge_labels(hard_labels, pov_labels)
|
||||
hard_item = deps.sanitize_scene_text_for_cast(
|
||||
deps.sanitize_hardcore_environment_anchors(hard.get("item")),
|
||||
hard_labels,
|
||||
)
|
||||
hard_role_graph = deps.sanitize_scene_text_for_cast(
|
||||
deps.sanitize_hardcore_environment_anchors(hard.get("source_role_graph") or hard.get("role_graph")),
|
||||
hard_labels,
|
||||
)
|
||||
hard_item = deps.natural_label_text(hard_item, hard_labels)
|
||||
hard_role_graph = deps.natural_label_text(hard_role_graph, hard_labels)
|
||||
hard_axis_values = deps.sanitize_hardcore_axis_values(hard.get("item_axis_values"))
|
||||
hard_detail_density = deps.normalize_hardcore_detail_density(
|
||||
hard.get("hardcore_detail_density") or row.get("hardcore_detail_density") or options.get("hardcore_detail_density")
|
||||
)
|
||||
hard_action = deps.hardcore_action_sentence(
|
||||
hard_role_graph,
|
||||
hard_item,
|
||||
hard_source_composition,
|
||||
hard_axis_values,
|
||||
hard_detail_density,
|
||||
deps.row_action_family(hard) or deps.row_action_family(row),
|
||||
)
|
||||
hard_action = deps.pov_action_phrase(
|
||||
hard_action,
|
||||
pov_labels,
|
||||
hard_role_graph,
|
||||
hard_item,
|
||||
hard_source_composition,
|
||||
hard_axis_values,
|
||||
hard_detail_density,
|
||||
)
|
||||
hard_output_composition = deps.pov_composition_text(hard_composition, pov_labels)
|
||||
same_soft_cast = options.get("softcore_cast") == "same_as_hardcore"
|
||||
soft_output_composition = deps.pov_composition_text(soft.get("composition"), pov_labels if same_soft_cast else [])
|
||||
soft_cast_presence = deps.softcore_cast_presence_phrase(
|
||||
same_cast=same_soft_cast,
|
||||
pov_labels=pov_labels if same_soft_cast else [],
|
||||
cast_label=deps.label_join(soft_labels),
|
||||
woman_label="the woman",
|
||||
)
|
||||
partner_styling = row.get("softcore_partner_styling")
|
||||
if isinstance(partner_styling, dict):
|
||||
outfits = partner_styling.get("outfits")
|
||||
partner_outfit_text = "; ".join(deps.clean(item) for item in outfits if deps.clean(item)) if isinstance(outfits, list) else ""
|
||||
partner_pose = deps.clean(partner_styling.get("pose"))
|
||||
else:
|
||||
partner_outfit_text = ""
|
||||
partner_pose = ""
|
||||
partner_outfit_text = deps.filter_pov_labeled_clauses(partner_outfit_text, pov_labels)
|
||||
if pov_labels:
|
||||
partner_pose = ""
|
||||
partner_outfit_text = deps.natural_label_text(partner_outfit_text, soft_labels)
|
||||
|
||||
soft_expression = ""
|
||||
if not deps.expression_disabled(soft):
|
||||
soft_expression_source = deps.filter_pov_labeled_clauses(
|
||||
deps.clean(soft.get("character_expression_text")) or deps.clean(soft.get("expression")),
|
||||
pov_labels,
|
||||
)
|
||||
soft_expression = deps.natural_label_text(
|
||||
soft_expression_source,
|
||||
soft_labels,
|
||||
)
|
||||
hard_expression = ""
|
||||
if not deps.expression_disabled(hard):
|
||||
hard_expression_source = deps.filter_pov_labeled_clauses(
|
||||
deps.clean(hard.get("character_expression_text")) or deps.clean(hard.get("expression")),
|
||||
pov_labels,
|
||||
)
|
||||
hard_expression = deps.natural_label_text(
|
||||
hard_expression_source,
|
||||
hard_labels,
|
||||
)
|
||||
soft_item = deps.clean(soft.get("item"))
|
||||
soft_item_label = deps.clean(soft.get("softcore_item_prompt_label"))
|
||||
soft_item_phrase = ""
|
||||
if soft_item:
|
||||
soft_item_phrase = f"body exposure: {soft_item}" if soft_item_label == "Body exposure" else f"wearing {soft_item}"
|
||||
|
||||
soft_parts = [
|
||||
soft_cast_prose,
|
||||
soft_cast_presence,
|
||||
partner_outfit_text,
|
||||
partner_pose,
|
||||
deps.pov_soft_camera_phrase(pov_labels) if same_soft_cast else "",
|
||||
soft_item_phrase,
|
||||
f"{soft.get('pose')}" if soft.get("pose") else "",
|
||||
deps.expression_phrase(soft_expression),
|
||||
f"in {soft.get('scene_text')}" if soft.get("scene_text") else "",
|
||||
soft_camera_scene,
|
||||
deps.composition_phrase(soft_output_composition),
|
||||
soft_camera,
|
||||
soft_style if detail_level != "concise" else "",
|
||||
]
|
||||
hard_parts = [
|
||||
hard_action,
|
||||
deps.pov_camera_phrase(pov_labels),
|
||||
deps.natural_label_text(
|
||||
deps.filter_pov_labeled_clauses(deps.natural_clothing_state(row.get("hardcore_clothing_state"), hard_action), pov_labels),
|
||||
hard_labels,
|
||||
),
|
||||
hard_cast_prose,
|
||||
f"set in {hard_scene}" if hard_scene else "",
|
||||
hard_camera_scene,
|
||||
deps.expression_phrase(hard_expression),
|
||||
deps.composition_phrase(hard_output_composition, hard_action, detail_density=hard_detail_density),
|
||||
hard_camera,
|
||||
hard_style if detail_level != "concise" else "",
|
||||
]
|
||||
return KreaPairPrompts(
|
||||
soft_prompt=deps.paragraph(soft_parts),
|
||||
soft_negative=deps.combine_negative(row.get("softcore_negative_prompt")),
|
||||
hard_prompt=deps.paragraph(hard_parts),
|
||||
hard_negative=deps.combine_negative(row.get("hardcore_negative_prompt")),
|
||||
)
|
||||
|
||||
|
||||
def format_insta_pair(request: KreaPairFormatRequest, deps: KreaPairFormatDependencies) -> tuple[str, str, str, str]:
|
||||
return format_insta_pair_result(request, deps).as_tuple()
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import pov_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import pov_policy
|
||||
|
||||
|
||||
def pov_labels_from_value(value: Any) -> list[str]:
|
||||
return pov_policy.pov_labels_from_value(value)
|
||||
|
||||
|
||||
def merge_labels(*groups: list[str]) -> list[str]:
|
||||
return pov_policy.merge_labels(*groups)
|
||||
|
||||
|
||||
def filter_pov_labeled_clauses(text: Any, pov_labels: list[str]) -> str:
|
||||
return pov_policy.filter_pov_labeled_clauses(text, pov_labels)
|
||||
|
||||
|
||||
def pov_camera_phrase(pov_labels: list[str], softcore: bool = False) -> str:
|
||||
if not pov_labels:
|
||||
return ""
|
||||
if softcore:
|
||||
return (
|
||||
"Camera is the male participant's first-person creator view in one continuous frame, with him implied by perspective or foreground cues"
|
||||
)
|
||||
return (
|
||||
"Camera is the male participant's first-person view in one continuous frame; only his foreground hands or body cues appear"
|
||||
)
|
||||
|
||||
|
||||
def pov_composition_text(composition: Any, pov_labels: list[str]) -> str:
|
||||
return pov_policy.pov_composition_formatter_text(composition, pov_labels)
|
||||
@@ -0,0 +1,517 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import outercourse_action_policy as outercourse_policy
|
||||
from .krea_action_context import (
|
||||
axis_values_text,
|
||||
is_climax_text,
|
||||
is_oral_text,
|
||||
is_outercourse_text,
|
||||
is_toy_assisted_double_text,
|
||||
position_context_text,
|
||||
)
|
||||
from .krea_detail import limit_detail_for_density
|
||||
except ImportError: # Allows local smoke tests with `python -c`.
|
||||
import outercourse_action_policy as outercourse_policy
|
||||
from krea_action_context import (
|
||||
axis_values_text,
|
||||
is_climax_text,
|
||||
is_oral_text,
|
||||
is_outercourse_text,
|
||||
is_toy_assisted_double_text,
|
||||
position_context_text,
|
||||
)
|
||||
from krea_detail import limit_detail_for_density
|
||||
|
||||
|
||||
def _clean(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 pov_ejaculation_target(context: str) -> str:
|
||||
if any(token in context for token in ("face", "mouth", "lips", "tongue", "chin")):
|
||||
return "onto her face and chest"
|
||||
if any(token in context for token in ("lower back", "ass", "rear-entry", "face-down", "bent-over", "doggy")):
|
||||
return "across her ass, thighs, and lower back"
|
||||
if any(token in context for token in ("pussy", "open thighs", "thighs", "legs open")):
|
||||
return "across her pussy and thighs"
|
||||
return "onto her body"
|
||||
|
||||
|
||||
def pov_contact_clause(
|
||||
action: Any,
|
||||
role_graph: Any,
|
||||
hard_item: Any,
|
||||
axis_values: Any,
|
||||
context: str,
|
||||
) -> str:
|
||||
is_climax = is_climax_text(action, role_graph, hard_item, axis_values_text(axis_values))
|
||||
if is_climax:
|
||||
return f"as he ejaculates semen {pov_ejaculation_target(context)}"
|
||||
is_anal = any(
|
||||
token in context
|
||||
for token in (
|
||||
"anal",
|
||||
"into her ass",
|
||||
"penis entering ass",
|
||||
"ass stretched",
|
||||
"thrusts into her ass",
|
||||
)
|
||||
)
|
||||
contact = "as his penis penetrates her ass" if is_anal else "as his penis penetrates her pussy"
|
||||
if is_toy_assisted_double_text(action, role_graph, hard_item, axis_values_text(axis_values)):
|
||||
contact = f"{contact} while a toy is positioned at the second penetration point"
|
||||
return contact
|
||||
|
||||
|
||||
def pov_clean_detail(detail: Any, context: str, detail_density: str) -> str:
|
||||
detail = _clean(detail).strip(" .;")
|
||||
if not detail:
|
||||
return ""
|
||||
detail = re.sub(r"\bthe POV viewer\b", "the viewer", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bthe man's\b", "the viewer's", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bthe man\b", "the viewer", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bhe\b", "the viewer", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bhis\b", "the viewer's", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bhim\b", "the viewer", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(
|
||||
r"^(?:missionary|cowgirl|reverse cowgirl|doggy style|standing sex|spooning sex|edge-supported|edge-of-bed|raised edge|kneeling straddle|lotus sex|bent-over|face-down ass-up|side-lying|kneeling rear-entry)\s+(?:position|pose)\s+(?:featuring|with|while|,)?\s*",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"^(?:kneeling oral|standing oral|chair oral|side-lying oral|sixty-nine|edge-supported oral|edge-of-bed oral|reclining cunnilingus|straddled oral|spread-leg oral)\s+(?:position|pose),?\s*",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(r"^(?:featuring|with)\s+", "", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(
|
||||
r"^(?:full-body|explicit|close-contact|deep|hardcore|vaginal|anal)?\s*(?:penetrative sex|vaginal sex|anal sex|penetration with visible genital contact|hardcore vaginal thrusting|hardcore anal thrusting),?\s*",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r"\b(?:front-facing|close-up|wide full-body|wide|overhead|mirror-reflected|low-angle|side-profile|bed-level)\s+view of\b",
|
||||
"visible",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r",?\s*\bthe viewer is behind her at hip level with (?:his|the viewer's) hands on her hips in the foreground as (?:his|the viewer's) penis (?:thrusts into her|penetrates her pussy)\b",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(
|
||||
r",?\s*\bthe woman is on all fours directly in front of the viewer with hips raised and back arched\b",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if any(token in context for token in ("ass raised", "on all fours", "doggy", "rear-entry", "bent-over", "face-down")):
|
||||
detail = re.sub(
|
||||
r",?\s*\b(?:one body pinned under another|bodies stacked close together|bodies tangled on the sheets)\b",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if "toy is positioned at the second penetration point" in context:
|
||||
detail = re.sub(
|
||||
r",?\s*\b(?:toy aligned for a second penetration point|toy-assisted second contact aligned behind the body)\b",
|
||||
"",
|
||||
detail,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
detail = re.sub(r"\bwith with\b", "with", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\s*,\s*", ", ", detail)
|
||||
detail = re.sub(r",\s*,", ",", detail).strip(" ,;")
|
||||
return limit_detail_for_density(detail, detail_density, is_climax_text(context, detail))
|
||||
|
||||
|
||||
def pov_clean_oral_detail(detail: Any, context: str, detail_density: str) -> str:
|
||||
detail = pov_clean_detail(detail, context, detail_density)
|
||||
if not detail:
|
||||
return ""
|
||||
duplicate_patterns = (
|
||||
r"\bthe woman takes the viewer's penis in her mouth with\s+",
|
||||
r"\bthe woman takes the viewer's penis in her mouth\b,?\s*",
|
||||
r"\bher mouth on the viewer's penis\b,?\s*",
|
||||
r"\bthe viewer's mouth on the woman's pussy\b,?\s*",
|
||||
r"\bmouth on the viewer's penis\b,?\s*",
|
||||
r"\bmouth on the woman's pussy\b,?\s*",
|
||||
)
|
||||
for pattern in duplicate_patterns:
|
||||
detail = re.sub(pattern, "", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\bwith\s+(?=[,;.]|$)", "", detail, flags=re.IGNORECASE)
|
||||
detail = re.sub(r"\s*,\s*", ", ", detail)
|
||||
detail = re.sub(r",\s*,", ",", detail)
|
||||
return _clean(detail).strip(" ,;")
|
||||
|
||||
|
||||
def pov_hardcore_pose_sentence(
|
||||
action: Any,
|
||||
role_graph: Any,
|
||||
hard_item: Any,
|
||||
composition: Any = "",
|
||||
axis_values: Any = None,
|
||||
detail_density: str = "balanced",
|
||||
) -> str:
|
||||
context = position_context_text(role_graph, hard_item, composition, axis_values)
|
||||
action_text = _clean(action)
|
||||
action_lower = action_text.lower()
|
||||
if not context:
|
||||
context = action_lower
|
||||
position_text = ""
|
||||
if isinstance(axis_values, dict):
|
||||
position_text = _clean(axis_values.get("position", "")).lower()
|
||||
position_context = position_text or context
|
||||
|
||||
def sentence(base: str) -> str:
|
||||
details = ""
|
||||
if ";" in action_text:
|
||||
details = pov_clean_detail(action_text.split(";", 1)[1], f"{context} {base}", detail_density)
|
||||
return f"{base}; {details}" if details else base
|
||||
|
||||
def outercourse_sentence(base: str) -> str:
|
||||
return _clean(base).rstrip(".")
|
||||
|
||||
def oral_sentence(base: str) -> str:
|
||||
details = ""
|
||||
if ";" in action_text:
|
||||
details = pov_clean_oral_detail(action_text.split(";", 1)[1], f"{context} {base}", detail_density)
|
||||
return _clean(f"{base}; {details}" if details else base).rstrip(".")
|
||||
|
||||
def oral_direction() -> tuple[bool, bool]:
|
||||
oral_context = f"{context} {action_lower}"
|
||||
woman_gives = any(
|
||||
token in oral_context
|
||||
for token in (
|
||||
"fellatio",
|
||||
"blowjob",
|
||||
"deepthroat",
|
||||
"penis sucking",
|
||||
"penis in her mouth",
|
||||
"mouth on the viewer's penis",
|
||||
"mouth on viewer's penis",
|
||||
"takes the viewer's penis",
|
||||
"takes the man's penis",
|
||||
"mouth at penis level",
|
||||
"lips wrapped",
|
||||
)
|
||||
)
|
||||
man_gives = any(
|
||||
token in oral_context
|
||||
for token in (
|
||||
"cunnilingus",
|
||||
"pussy licking",
|
||||
"mouth on the woman's pussy",
|
||||
"mouth on her pussy",
|
||||
"mouth pressed to her pussy",
|
||||
"tongue on pussy",
|
||||
"face-sitting",
|
||||
"straddles his face",
|
||||
"straddling the viewer's face",
|
||||
)
|
||||
)
|
||||
if "sixty-nine" in oral_context:
|
||||
return True, True
|
||||
return woman_gives, man_gives
|
||||
|
||||
penetrative_tokens = (
|
||||
"penetrat",
|
||||
"thrust",
|
||||
"anal",
|
||||
"cowgirl",
|
||||
"missionary",
|
||||
"doggy",
|
||||
"rear-entry",
|
||||
"spooning",
|
||||
"bent-over",
|
||||
"face-down",
|
||||
"ejaculat",
|
||||
"semen",
|
||||
"cumshot",
|
||||
"climax",
|
||||
)
|
||||
has_penetrative_context = any(token in context or token in action_lower for token in penetrative_tokens)
|
||||
|
||||
if (
|
||||
"face-sitting" in context
|
||||
or "face sitting" in context
|
||||
or ("straddles" in context and "face" in context and "pussy" in context)
|
||||
):
|
||||
return outercourse_sentence(
|
||||
"The woman is above the camera in a close first-person underview, straddling the viewer's face with her thighs on both sides of his head; "
|
||||
"her pussy is directly over the viewer's mouth in the lower foreground, tongue contact visible from below"
|
||||
)
|
||||
|
||||
if is_outercourse_text(context, action_lower):
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(position_text)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_GENERIC:
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(context, action_lower)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB:
|
||||
return outercourse_sentence(
|
||||
"The woman kneels low between the viewer's open thighs with her torso bent forward over his pelvis; "
|
||||
"both hands push her breasts inward around the viewer's penis in the lower foreground, the penis held between her breasts, "
|
||||
"with her chin and lips directly above the glans at the tip"
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE:
|
||||
return outercourse_sentence(
|
||||
"The woman bends forward and kneels very low between the viewer's open thighs with her shoulders between his knees; "
|
||||
"her face is below the viewer's penis at testicle height, mouth and tongue licking the viewer's balls while his penis points upward in the lower foreground above her forehead"
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING:
|
||||
return outercourse_sentence(
|
||||
"The woman bends forward between the viewer's open thighs with her head low under the viewer's penis; "
|
||||
"her face is just under the penis while her tongue touches the underside from the base toward the glans at the tip, "
|
||||
"one hand steadying the base of the viewer's penis in the lower foreground"
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB:
|
||||
return outercourse_sentence(
|
||||
"The woman kneels between the viewer's open thighs with her torso leaning forward and face visible behind the viewer's penis; "
|
||||
"one hand grips and strokes the viewer's penis in the lower foreground while the other hand steadies its base, "
|
||||
"thumb and fingers visible around the penis as she strokes toward the glans"
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB:
|
||||
return outercourse_sentence(
|
||||
"The woman faces the viewer with her hips back, torso visible behind her raised legs, and both knees bent open toward the camera; "
|
||||
"her soles wrap around the penis shaft in the lower foreground, toes curled around the penis shaft with her face visible beyond her feet"
|
||||
)
|
||||
return outercourse_sentence(
|
||||
"The woman stays close to the viewer's pelvis, keeping the non-penetrative contact centered in the lower foreground with her face visible behind the contact"
|
||||
)
|
||||
|
||||
if is_oral_text(context, action_lower) and not has_penetrative_context:
|
||||
woman_gives, man_gives = oral_direction()
|
||||
if "sixty-nine" in position_context:
|
||||
return oral_sentence(
|
||||
"POV sixty-nine oral position: the woman lies head-to-hips over the viewer, her pelvis close to his face and her head lowered toward his hips; "
|
||||
"her mouth on the viewer's penis and the viewer's mouth on the woman's pussy, with her torso, hips, mouth, and the viewer's lower-foreground body cues aligned in one first-person frame"
|
||||
)
|
||||
if "side-lying oral" in position_context or "side lying oral" in position_context:
|
||||
if woman_gives and not man_gives:
|
||||
return oral_sentence(
|
||||
"POV side-lying oral position: the viewer lies on his side with hips angled toward the woman while she lies beside his thighs; "
|
||||
"her head stays at penis height with her mouth on the viewer's penis, shoulders and hands close to his pelvis in the lower foreground"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV side-lying cunnilingus position: the woman lies on her side with her top thigh lifted while the viewer lies beside her hips; "
|
||||
"his face is at pussy height, with her thigh, hip, and torso forming a clear side-profile first-person frame"
|
||||
)
|
||||
if (
|
||||
"edge-supported oral" in position_context
|
||||
or "edge-of-bed oral" in position_context
|
||||
or "edge of bed oral" in position_context
|
||||
or "raised edge" in position_context
|
||||
):
|
||||
if woman_gives and not man_gives:
|
||||
return oral_sentence(
|
||||
"POV raised-edge oral position: the viewer sits at the raised edge with legs apart while the woman kneels directly between his thighs; "
|
||||
"her head is at penis height, mouth on the viewer's penis, with his thighs framing her shoulders in the lower foreground"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV raised-edge cunnilingus position: the woman reclines at the raised edge with thighs open toward the viewer; "
|
||||
"the viewer kneels between her legs with his face at pussy height, her hips and open thighs framing the first-person view"
|
||||
)
|
||||
if "chair oral" in position_context:
|
||||
if woman_gives and not man_gives:
|
||||
return oral_sentence(
|
||||
"POV chair oral position: the viewer sits in a chair with legs apart while the woman kneels between the viewer's thighs; "
|
||||
"her head is low at his pelvis, mouth on the viewer's penis, with chair seat, thighs, and hands anchoring the lower foreground"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV chair cunnilingus position: the woman sits in the chair with thighs open while the viewer kneels between her legs; "
|
||||
"his face is at pussy height and her hips, knees, and chair seat define the first-person geometry"
|
||||
)
|
||||
if "standing oral" in position_context:
|
||||
if man_gives and not woman_gives:
|
||||
return oral_sentence(
|
||||
"POV standing cunnilingus position: the woman stands braced with one thigh lifted while the viewer kneels in front of her; "
|
||||
"his face is at pussy height, with her raised thigh, hips, and standing leg clearly framing the view"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV standing oral position: the viewer stands over her with hips forward while the woman kneels directly in front of him at hip height; "
|
||||
"her head is tilted up at penis level, mouth on the viewer's penis, with his thighs and hands in the lower foreground"
|
||||
)
|
||||
if (
|
||||
"reclining cunnilingus" in position_context
|
||||
or "spread-leg oral" in position_context
|
||||
or "open-thigh" in position_context
|
||||
or "open thigh" in position_context
|
||||
):
|
||||
if woman_gives and not man_gives:
|
||||
return oral_sentence(
|
||||
"POV reclining oral position: the viewer reclines with thighs apart while the woman kneels low between his legs; "
|
||||
"her face stays at penis height with her mouth on the viewer's penis and his thighs framing the first-person view"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV open-thigh cunnilingus position: the woman reclines on her back with thighs spread toward the viewer; "
|
||||
"the viewer kneels between her legs with his face at pussy height, her knees, hips, and torso aligned toward the camera"
|
||||
)
|
||||
if "straddled oral" in position_context:
|
||||
if woman_gives and not man_gives:
|
||||
return oral_sentence(
|
||||
"POV straddled oral position: the viewer leans forward near the woman's face while she kneels below his pelvis; "
|
||||
"her mouth stays on the viewer's penis with her head tilted upward and his thighs framing the lower foreground"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV straddled cunnilingus position: the woman straddles above the viewer's face with her thighs framing his head; "
|
||||
"her pussy stays directly over the viewer's mouth in a close first-person oral frame"
|
||||
)
|
||||
if "kneeling oral" in position_context or "kneeling" in position_context:
|
||||
if man_gives and not woman_gives:
|
||||
return oral_sentence(
|
||||
"POV kneeling cunnilingus position: the woman kneels with thighs parted and hips angled forward while the viewer kneels in front of her; "
|
||||
"his face is at pussy height, with her knees, hips, and torso readable from the first-person angle"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV kneeling oral position: the viewer stands over her with hips forward while the woman kneels directly in front of him; "
|
||||
"her head is at penis height, mouth on the viewer's penis, shoulders below his hips and his thighs framing the lower foreground"
|
||||
)
|
||||
if man_gives and not woman_gives:
|
||||
return oral_sentence(
|
||||
"POV cunnilingus position: the woman lies back with thighs open toward the viewer while he kneels between her legs; "
|
||||
"his face is at pussy height, with her knees, hips, and torso forming the first-person frame"
|
||||
)
|
||||
return oral_sentence(
|
||||
"POV oral position: the woman kneels close at the viewer's pelvis with her head at penis height; "
|
||||
"her mouth is on the viewer's penis, shoulders between his thighs, and the viewer's hands or thighs anchor the lower foreground"
|
||||
)
|
||||
|
||||
if not has_penetrative_context:
|
||||
return ""
|
||||
oral_only = any(token in context for token in ("oral", "blowjob", "cunnilingus", "mouth on", "penis in her mouth"))
|
||||
if oral_only and not any(token in context for token in ("penetrat", "thrust", "anal", "ejaculat", "semen", "cumshot", "climax")):
|
||||
return ""
|
||||
|
||||
contact = pov_contact_clause(action, role_graph, hard_item, axis_values, context)
|
||||
|
||||
if "reverse cowgirl" in position_context:
|
||||
return sentence(
|
||||
"POV reverse cowgirl position: the viewer lies on his back while the woman straddles his hips facing away; "
|
||||
f"her back, ass, thighs, and the viewer's foreground legs are visible {contact}"
|
||||
)
|
||||
if "cowgirl" in position_context or "straddling a partner" in position_context or "squatting on top" in position_context:
|
||||
return sentence(
|
||||
"POV cowgirl position: the viewer lies on his back while the woman straddles his hips facing him; "
|
||||
f"her torso, hips, and open thighs fill the frame from below {contact}"
|
||||
)
|
||||
if "lotus" in position_context or "seated in a partner's lap" in position_context:
|
||||
return sentence(
|
||||
"POV lotus position: the viewer sits upright while the woman sits in his lap facing him with her legs around his hips; "
|
||||
f"her torso and hips stay close to the viewer {contact}"
|
||||
)
|
||||
if "kneeling straddle" in position_context:
|
||||
return sentence(
|
||||
"POV kneeling straddle position: the viewer kneels upright while the woman straddles his hips facing him; "
|
||||
f"both torsos are upright and her hips press directly against him {contact}"
|
||||
)
|
||||
if "face-down" in position_context or "face down" in position_context:
|
||||
return sentence(
|
||||
"The woman is seen from behind with her ass raised toward the POV viewer, lying face-down with hips lifted; "
|
||||
f"the viewer looks down at her raised ass with foreground hands on her hips {contact}"
|
||||
)
|
||||
if (
|
||||
"edge-supported" in position_context
|
||||
or "raised edge" in position_context
|
||||
or "edge of bed" in position_context
|
||||
or "bed edge" in position_context
|
||||
or (not position_text and "kneels between her legs" in context)
|
||||
):
|
||||
return sentence(
|
||||
"POV raised-edge penetration position: the woman reclines at the raised edge with thighs open toward the viewer; "
|
||||
f"the viewer kneels between her legs with his hands near her hips {contact}"
|
||||
)
|
||||
if "standing" in position_context:
|
||||
return sentence(
|
||||
"POV standing rear-entry position: the woman stands braced in front of the viewer with hips angled back and legs steady; "
|
||||
f"the viewer stands behind her at hip level {contact}"
|
||||
)
|
||||
if "spooning" in position_context or "side-lying" in position_context or "lies on her side" in position_context:
|
||||
return sentence(
|
||||
"POV side-lying sex position: the woman lies on her side in front of the viewer with thighs parted; "
|
||||
f"the viewer is behind her along the same body line {contact}"
|
||||
)
|
||||
if "doggy" in position_context or "all fours" in position_context or "rear-entry" in position_context:
|
||||
return sentence(
|
||||
"The woman is seen from behind with her ass raised toward the POV viewer, on all fours directly in front of him with hips high and back arched; "
|
||||
f"the viewer looks down at her raised ass with his hands on her hips in the foreground {contact}"
|
||||
)
|
||||
if "kneeling" in position_context:
|
||||
return sentence(
|
||||
"POV kneeling rear-entry position: the woman kneels forward in front of the viewer with hips raised and thighs apart; "
|
||||
f"the viewer kneels behind her at hip level with foreground hands near her waist {contact}"
|
||||
)
|
||||
if "bent-over" in position_context or "bent over" in position_context or "bent forward" in position_context:
|
||||
return sentence(
|
||||
"The woman is seen from behind with her ass raised toward the POV viewer, bent forward at the waist with hips lifted and head turned back; "
|
||||
f"the viewer looks down at her raised ass from behind with foreground hands near her hips {contact}"
|
||||
)
|
||||
if "missionary" in position_context or (not position_text and "lies on her back" in context and ("legs open" in context or "thighs open" in context)):
|
||||
return sentence(
|
||||
"POV missionary position: the woman lies on her back with legs open around the viewer's hips; "
|
||||
f"the viewer is above her with foreground arms braced beside her body {contact}"
|
||||
)
|
||||
return sentence(
|
||||
"POV penetrative sex position: the woman is directly in front of the viewer with legs open around his hips; "
|
||||
f"the viewer's foreground hands and body position define the first-person angle {contact}"
|
||||
)
|
||||
|
||||
|
||||
def pov_action_phrase(
|
||||
action: Any,
|
||||
pov_labels: list[str],
|
||||
role_graph: Any = "",
|
||||
hard_item: Any = "",
|
||||
composition: Any = "",
|
||||
axis_values: Any = None,
|
||||
detail_density: str = "balanced",
|
||||
) -> str:
|
||||
rendered = _clean(action)
|
||||
if not rendered or not pov_labels:
|
||||
return rendered
|
||||
if "Man A" in pov_labels:
|
||||
pov_sentence = pov_hardcore_pose_sentence(
|
||||
rendered,
|
||||
role_graph,
|
||||
hard_item,
|
||||
composition,
|
||||
axis_values,
|
||||
detail_density,
|
||||
)
|
||||
if pov_sentence:
|
||||
return pov_sentence
|
||||
for label in sorted(pov_labels, key=len, reverse=True):
|
||||
escaped = re.escape(label)
|
||||
rendered = re.sub(rf"\b{escaped}'s\b", "the viewer's", rendered)
|
||||
rendered = re.sub(rf"\b{escaped}\b", "the viewer", rendered)
|
||||
if "Man A" in pov_labels:
|
||||
rendered = re.sub(r"\bthe man's\b", "the viewer's", rendered, flags=re.IGNORECASE)
|
||||
rendered = re.sub(r"\bthe man\b", "the viewer", rendered, flags=re.IGNORECASE)
|
||||
rendered = re.sub(r"\bhe\b", "the viewer", rendered, flags=re.IGNORECASE)
|
||||
rendered = re.sub(r"\bhim\b", "the viewer", rendered, flags=re.IGNORECASE)
|
||||
rendered = re.sub(r"\bhis\b", "the viewer's", rendered, flags=re.IGNORECASE)
|
||||
rendered = re.sub(
|
||||
r"\bthe viewer lies on the viewer's back under her\b",
|
||||
"the viewer reclines underneath her",
|
||||
rendered,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
rendered = re.sub(
|
||||
r"\bthe viewer lies on the viewer's back\b",
|
||||
"the viewer reclines",
|
||||
rendered,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
rendered = re.sub(r"\bthe viewer is positioned\b", "the POV camera is positioned", rendered, flags=re.IGNORECASE)
|
||||
return rendered
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaRowFields:
|
||||
subject_type: str
|
||||
primary: str
|
||||
item: str
|
||||
scene: str
|
||||
pose: str
|
||||
expression: str
|
||||
composition: str
|
||||
source_composition: str
|
||||
camera: str
|
||||
camera_scene: str
|
||||
style: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreaRowFieldDependencies:
|
||||
clean: Callable[[Any], str]
|
||||
row_value: Callable[[dict[str, Any], str, tuple[str, ...]], str]
|
||||
camera_phrase: Callable[[dict[str, Any]], str]
|
||||
camera_scene_phrase: Callable[[dict[str, Any]], str]
|
||||
style_phrase: Callable[[dict[str, Any], str], str]
|
||||
expression_disabled: Callable[[dict[str, Any]], bool]
|
||||
|
||||
|
||||
def _without_vertical_prefix(text: str) -> str:
|
||||
return re.sub(r"^vertical\s+", "", text, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _clean_item_suffix(text: str) -> str:
|
||||
return re.sub(r",?\s*(fashion editorial|resort) styling$", "", text, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def extract_krea_row_fields(
|
||||
row: dict[str, Any],
|
||||
style_mode: str,
|
||||
deps: KreaRowFieldDependencies,
|
||||
) -> KreaRowFields:
|
||||
item = deps.row_value(row, "item", ("Sexual pose", "Erotic outfit", "Clothing")) or deps.clean(
|
||||
row.get("custom_item")
|
||||
)
|
||||
item = _clean_item_suffix(item)
|
||||
expression = ""
|
||||
if not deps.expression_disabled(row):
|
||||
expression = deps.row_value(row, "character_expression_text", ()) or deps.row_value(
|
||||
row,
|
||||
"expression",
|
||||
("Facial expressions", "Facial expression"),
|
||||
)
|
||||
composition = _without_vertical_prefix(deps.row_value(row, "composition", ("Composition",)))
|
||||
source_composition = _without_vertical_prefix(deps.clean(row.get("source_composition")) or composition)
|
||||
return KreaRowFields(
|
||||
subject_type=deps.clean(row.get("subject_type")),
|
||||
primary=deps.clean(row.get("primary_subject")),
|
||||
item=item,
|
||||
scene=deps.row_value(row, "scene_text", ("Setting", "Scene")) or deps.clean(row.get("scene")),
|
||||
pose=deps.row_value(row, "pose", ("Sexual pose", "Pose")),
|
||||
expression=expression,
|
||||
composition=composition,
|
||||
source_composition=source_composition,
|
||||
camera=deps.camera_phrase(row),
|
||||
camera_scene=deps.camera_scene_phrase(row),
|
||||
style=deps.style_phrase(row, style_mode),
|
||||
)
|
||||
@@ -0,0 +1,666 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from .category_library import load_composition_pool_library, load_scene_pool_library
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from category_library import load_composition_pool_library, load_scene_pool_library
|
||||
|
||||
|
||||
LOCATION_POOL_PRESETS = {
|
||||
"custom_only": (),
|
||||
"all_json_locations": ("*",),
|
||||
"casual_all": ("casual_",),
|
||||
"casual_urban": ("casual_urban_scenes",),
|
||||
"casual_summer": ("casual_summer_scenes",),
|
||||
"casual_home": ("casual_lounge_scenes",),
|
||||
"casual_smart": ("casual_smart_scenes",),
|
||||
"creator_softcore": ("softcore_creator_scenes", "mirror_scenes", "boudoir_bedroom_scenes"),
|
||||
"mirror_rooms": ("mirror_scenes", "hardcore_mirror_scenes"),
|
||||
"boudoir_bedroom": ("boudoir_bedroom_scenes", "hardcore_bed_scenes"),
|
||||
"fetish_studio": ("fetish_studio_scenes",),
|
||||
"costume_backstage": ("costume_backstage_scenes",),
|
||||
"hardcore_all": ("hardcore_",),
|
||||
"hardcore_private": ("hardcore_private_scenes",),
|
||||
"hardcore_bed": ("hardcore_bed_scenes",),
|
||||
"hardcore_penetrative": ("hardcore_penetrative_scenes",),
|
||||
"hardcore_oral": ("hardcore_oral_scenes",),
|
||||
"hardcore_anal": ("hardcore_anal_scenes",),
|
||||
"hardcore_threesome": ("hardcore_threesome_scenes",),
|
||||
"hardcore_group": ("hardcore_group_scenes",),
|
||||
"hardcore_climax": ("hardcore_climax_scenes",),
|
||||
}
|
||||
|
||||
COMPOSITION_POOL_PRESETS = {
|
||||
"custom_only": (),
|
||||
"all_json_compositions": ("*",),
|
||||
"casual_all": ("casual_", "streetwear_", "summer_", "cozy_home_", "smart_casual_", "athleisure_"),
|
||||
"creator_softcore": ("softcore_creator_compositions", "boudoir_body_compositions"),
|
||||
"hardcore_all": ("hardcore_",),
|
||||
"hardcore_explicit": ("hardcore_explicit_compositions",),
|
||||
"no_outfit_check": (),
|
||||
}
|
||||
|
||||
COMPOSITION_INLINE_PRESETS = {
|
||||
"no_outfit_check": [
|
||||
"environment-led frame with no outfit-check wording",
|
||||
"mid-distance scene composition with the room context readable",
|
||||
"partly occluded candid frame through foreground architecture",
|
||||
"long perspective frame using repeating background structure",
|
||||
"waist-up or three-quarter frame without bag, shoes, or footwear emphasis",
|
||||
],
|
||||
}
|
||||
|
||||
THEMATIC_LOCATION_PRESETS = {
|
||||
"classical_library": {
|
||||
"locations": [
|
||||
{"slug": "classical_large_library", "prompt": "grand classical library hall with towering dark-wood bookshelves, carved columns, rolling ladders, marble floor, warm brass lamps, arched windows, and deep quiet academic atmosphere"},
|
||||
{"slug": "old_world_reading_room", "prompt": "large old-world reading room with floor-to-ceiling bookshelves, heavy wooden tables, green banker lamps, leather chairs, tall arched windows, and warm amber evening light"},
|
||||
{"slug": "hidden_library_stacks", "prompt": "quiet library stacks with endless tall bookshelves, narrow aisles, rolling ladders, brass lamps, and hidden sightlines between shelves"},
|
||||
],
|
||||
"compositions": [
|
||||
"narrow aisle frame between towering bookshelves",
|
||||
"over-the-shoulder view through foreground books",
|
||||
"warm lamp-lit reading-table composition",
|
||||
"long vanishing-point frame down repeated library stacks",
|
||||
"partly hidden frame behind carved columns and shelf edges",
|
||||
],
|
||||
},
|
||||
"creator_bedroom": {
|
||||
"locations": [
|
||||
{"slug": "creator_bedroom_ring_light", "prompt": "private creator bedroom with a ring light, phone tripod, rumpled bedding, and warm lamps"},
|
||||
{"slug": "hotel_bed_phone_tripod", "prompt": "hotel bed content setup with a phone on a mini tripod, city lights, and satin bedding"},
|
||||
{"slug": "studio_bedroom_backdrop", "prompt": "small creator studio with a bed, seamless backdrop, ring light, and visible phone stand"},
|
||||
],
|
||||
"compositions": [
|
||||
"creator bedroom frame with bed edge and phone tripod readable",
|
||||
"vertical creator-shot frame with ring light and warm lamps behind the body",
|
||||
"bedside content setup composition with bedding and tripod placement visible",
|
||||
"close room-context frame keeping the phone setup and bed plane clear",
|
||||
],
|
||||
},
|
||||
"mirror_room": {
|
||||
"locations": [
|
||||
{"slug": "large_bedroom_mirror_selfie", "prompt": "large bedroom mirror with the phone visible, bed behind the subject, and warm side lamps"},
|
||||
{"slug": "neon_mirror_wall", "prompt": "neon mirror wall with glossy floor reflections and saturated magenta-blue edge light"},
|
||||
{"slug": "gold_vanity_mirror", "prompt": "gold-framed vanity mirror with makeup lights, silk fabric, and close reflected framing"},
|
||||
],
|
||||
"compositions": [
|
||||
"mirror-room frame with the reflected phone angle and room depth aligned",
|
||||
"full-length mirror composition keeping reflection lines readable",
|
||||
"vanity-mirror frame with bulbs and reflected body plane visible",
|
||||
"glossy mirror-wall composition with floor reflection line at the lower edge",
|
||||
],
|
||||
},
|
||||
"boudoir_bedroom": {
|
||||
"locations": [
|
||||
{"slug": "warm_boudoir_canopy_bed", "prompt": "warm boudoir bedroom with satin sheets, canopy curtains, low lamplight, and bedside phone framing"},
|
||||
{"slug": "velvet_headboard_bedroom", "prompt": "velvet headboard bedroom with gold lamps, rumpled bedding, and close sensual framing"},
|
||||
{"slug": "hotel_satin_bedroom", "prompt": "luxury hotel bedroom with satin bedding, city glow, and a visible mirror near the bed"},
|
||||
],
|
||||
"compositions": [
|
||||
"boudoir bedroom frame with sheet folds and warm bedroom depth visible",
|
||||
"bed-edge composition with pillows, lamp glow, and headboard depth",
|
||||
"low bedroom frame using bedding lines as the foreground anchor",
|
||||
"hotel-bed composition with satin sheets and mirror edge readable",
|
||||
],
|
||||
},
|
||||
"fetish_studio": {
|
||||
"locations": [
|
||||
{"slug": "black_latex_studio_floor", "prompt": "dark private studio with glossy black floor reflections, rim light, and a phone tripod"},
|
||||
{"slug": "red_velvet_lacquer_room", "prompt": "red velvet room with black lacquer furniture, low spotlights, and reflective surfaces"},
|
||||
{"slug": "chrome_fetish_set", "prompt": "chrome studio set with reflective panels, black curtains, and hard-edged erotic lighting"},
|
||||
],
|
||||
"compositions": [
|
||||
"private studio frame with glossy floor reflection and controlled rim light",
|
||||
"lacquer-room composition with reflective furniture and backdrop depth",
|
||||
"chrome studio frame with panel seams and lighting stands readable",
|
||||
"low studio-floor composition keeping reflection lines and set geometry clear",
|
||||
],
|
||||
},
|
||||
"semi_public_affair": {
|
||||
"locations": [
|
||||
{"slug": "hotel_corridor_affair", "prompt": "upscale hotel corridor with repeating numbered doors, patterned carpet, brass wall lamps, luggage carts, and a secluded corner near a service alcove"},
|
||||
{"slug": "hotel_service_hall", "prompt": "luxury hotel service corridor with repeating linen carts, beige doors, utility shelves, wall sconces, and a private turn away from the main hallway"},
|
||||
{"slug": "parking_garage_hidden", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted floor lines, low fluorescent light, and shadowed blind spots"},
|
||||
{"slug": "office_afterhours_affair", "prompt": "empty corporate office after hours with rows of glass partitions, repeating desks, blinds, copier alcove, muted city light, and no visible coworkers"},
|
||||
{"slug": "library_stacks_secret", "prompt": "classical library stacks with endless tall bookshelves, narrow aisles, rolling ladders, carved columns, warm brass lamps, and hidden sightlines between shelves"},
|
||||
],
|
||||
"compositions": [
|
||||
"partly concealed frame from behind a doorway edge",
|
||||
"long corridor vanishing-point composition with repeated doors",
|
||||
"hidden alcove frame with foreground obstruction",
|
||||
"surveillance-like candid angle from across the empty space",
|
||||
"tight frame using pillars, shelves, or walls to block side visibility",
|
||||
],
|
||||
},
|
||||
"hotel_corridor": {
|
||||
"locations": [
|
||||
{"slug": "upscale_hotel_corridor", "prompt": "upscale hotel corridor with repeating doors, patterned carpet, brass wall lamps, quiet service alcoves, and warm late-night light"},
|
||||
{"slug": "hotel_service_alcove", "prompt": "hotel service alcove with linen carts, beige utility doors, folded towels, soft wall sconces, and a secluded turn off the main corridor"},
|
||||
{"slug": "boutique_hotel_stair_landing", "prompt": "boutique hotel stair landing with repeating railings, framed wall panels, low amber lamps, and a quiet corner between floors"},
|
||||
],
|
||||
"compositions": [
|
||||
"long hallway frame with repeated doors receding behind the body",
|
||||
"corner-alcove composition partly hidden by a wall edge",
|
||||
"low corridor angle with patterned carpet leading lines",
|
||||
"over-the-shoulder frame toward a closed hotel-room door",
|
||||
],
|
||||
},
|
||||
"parking_garage": {
|
||||
"locations": [
|
||||
{"slug": "empty_parking_garage", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted bay lines, low fluorescent light, and deep shadowed corners"},
|
||||
{"slug": "underground_garage_corner", "prompt": "underground parking garage corner with numbered pillars, glossy concrete floor, parked cars, and blue-green fluorescent light"},
|
||||
{"slug": "rooftop_parking_deck_night", "prompt": "rooftop parking deck at night with repeated concrete barriers, distant city lights, painted lines, and open wind"},
|
||||
],
|
||||
"compositions": [
|
||||
"pillar-framed composition with repeated concrete columns",
|
||||
"low angle across painted parking lines",
|
||||
"hidden corner frame between parked cars",
|
||||
"wide empty garage frame with strong fluorescent perspective",
|
||||
],
|
||||
},
|
||||
"theater_backstage": {
|
||||
"locations": [
|
||||
{"slug": "old_theater_backstage", "prompt": "old theater backstage with repeated velvet curtains, prop racks, costume rails, bulb mirrors, dark wings, and narrow hidden passages"},
|
||||
{"slug": "cabaret_backstage_wings", "prompt": "cabaret backstage wings with red curtains, costume racks, vanity bulbs, stage ropes, and warm theatrical shadows"},
|
||||
{"slug": "prop_storage_corridor", "prompt": "theater prop storage corridor with stacked trunks, repeated scenery flats, rolling racks, and dim practical lamps"},
|
||||
],
|
||||
"compositions": [
|
||||
"frame between layered velvet curtains",
|
||||
"backstage mirror-bulb composition with costume racks behind",
|
||||
"hidden wing angle looking toward the stage light spill",
|
||||
"narrow prop-aisle frame with repeated vertical flats",
|
||||
],
|
||||
},
|
||||
"wine_cellar": {
|
||||
"locations": [
|
||||
{"slug": "private_wine_cellar", "prompt": "private wine cellar with repeating bottle racks, arched brick walls, narrow aisles, dim amber lamps, and secluded corners between shelves"},
|
||||
{"slug": "restaurant_wine_storage", "prompt": "restaurant wine storage room with stacked bottle shelves, crate rows, stone floor, soft utility light, and hidden service-door access"},
|
||||
{"slug": "arched_cellar_corridor", "prompt": "arched cellar corridor with repeated brick niches, wine racks, low golden lamps, and cool shadowed depth"},
|
||||
],
|
||||
"compositions": [
|
||||
"narrow aisle frame between repeated bottle racks",
|
||||
"arched brick corridor composition with warm lamps",
|
||||
"foreground bottle-rack occlusion framing the body",
|
||||
"low cellar angle with shelves receding behind",
|
||||
],
|
||||
},
|
||||
"museum_archive": {
|
||||
"locations": [
|
||||
{"slug": "museum_archive_room", "prompt": "museum archive room with repeating storage shelves, labeled boxes, rolling ladders, long work tables, soft overhead lights, and hidden aisles"},
|
||||
{"slug": "gallery_storage_backroom", "prompt": "gallery storage backroom with stacked frames, rolling racks, crate labels, clean concrete floor, and muted work lights"},
|
||||
{"slug": "rare_books_archive", "prompt": "rare-books archive with compact shelving, catalog drawers, reading lamps, archival boxes, and narrow private aisles"},
|
||||
],
|
||||
"compositions": [
|
||||
"hidden archive-aisle frame between storage shelves",
|
||||
"table-edge composition with labeled boxes in the background",
|
||||
"foreground crate or shelf occlusion",
|
||||
"long compact-shelving perspective with repeated rows",
|
||||
],
|
||||
},
|
||||
"laundromat_late_night": {
|
||||
"locations": [
|
||||
{"slug": "late_night_laundromat", "prompt": "late-night laundromat with repeating washing machines, chrome reflections, tiled floor, fluorescent lights, empty aisles, and a secluded back corner"},
|
||||
{"slug": "coin_laundry_back_row", "prompt": "coin laundry back row with stacked dryers, plastic folding tables, detergent shelves, buzzing fluorescent light, and no other customers"},
|
||||
{"slug": "laundromat_mirror_windows", "prompt": "quiet laundromat with mirrored machine doors, repeated round windows, tile floor, and cool blue night light through front glass"},
|
||||
],
|
||||
"compositions": [
|
||||
"repeating washer-door perspective behind the body",
|
||||
"folding-table edge frame with chrome reflections",
|
||||
"low tiled-floor angle down an empty machine row",
|
||||
"back-corner composition partly hidden by laundry machines",
|
||||
],
|
||||
},
|
||||
"train_station_lockers": {
|
||||
"locations": [
|
||||
{"slug": "train_station_locker_corridor", "prompt": "quiet train-station locker corridor with repeating metal lockers, tiled walls, vending machines, fluorescent light, and a hidden side alcove"},
|
||||
{"slug": "empty_platform_underpass", "prompt": "empty station underpass with tiled walls, repeated poster frames, stair railings, fluorescent lights, and late-night quiet"},
|
||||
{"slug": "station_service_passage", "prompt": "station service passage with repeating utility doors, metal lockers, warning stripes, and cool overhead light"},
|
||||
],
|
||||
"compositions": [
|
||||
"locker-row vanishing-point composition",
|
||||
"side-alcove frame partly blocked by metal lockers",
|
||||
"fluorescent underpass frame with repeated tile lines",
|
||||
"candid angle from behind a vending machine edge",
|
||||
],
|
||||
},
|
||||
"nightclub_back_hall": {
|
||||
"locations": [
|
||||
{"slug": "nightclub_back_hall", "prompt": "nightclub back hallway with black doors, repeated neon strips, coat-check racks, textured walls, and distant colored dance-floor light"},
|
||||
{"slug": "club_vip_corridor", "prompt": "VIP club corridor with velvet ropes, mirrored wall panels, low red light, repeated booths, and a private bend in the hallway"},
|
||||
{"slug": "music_venue_greenroom_hall", "prompt": "music venue greenroom corridor with stickered doors, cable cases, dim practical lamps, and repeated black curtains"},
|
||||
],
|
||||
"compositions": [
|
||||
"neon hallway frame with repeated dark doors",
|
||||
"partly hidden VIP-booth angle",
|
||||
"mirror-panel composition with colored light streaks",
|
||||
"tight backstage corridor frame with curtains at the edges",
|
||||
],
|
||||
},
|
||||
"restaurant_private_booth": {
|
||||
"locations": [
|
||||
{"slug": "restaurant_private_booth", "prompt": "dim restaurant private booth with high banquettes, repeating table lamps, dark wood partitions, folded napkins, and secluded sightlines"},
|
||||
{"slug": "empty_bistro_back_corner", "prompt": "empty bistro back corner with tiled floor, small round tables, brass lamps, mirrored walls, and a hidden booth"},
|
||||
{"slug": "afterhours_dining_room", "prompt": "after-hours dining room with stacked chairs, repeated tables, low amber sconces, and a quiet service doorway"},
|
||||
],
|
||||
"compositions": [
|
||||
"booth-partition frame with high seat backs blocking the sides",
|
||||
"table-edge composition with lamps repeating behind",
|
||||
"mirror-wall restaurant angle with dark wood partitions",
|
||||
"after-hours dining-room perspective through empty tables",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
text = str(value or "").lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "_", text)
|
||||
return text.strip("_")[:48] 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 location_pool_preset_choices() -> list[str]:
|
||||
pool_choices = [f"pool:{key}" for key in sorted(load_scene_pool_library())]
|
||||
return list(LOCATION_POOL_PRESETS) + pool_choices
|
||||
|
||||
|
||||
def composition_pool_preset_choices() -> list[str]:
|
||||
pool_choices = [f"pool:{key}" for key in sorted(load_composition_pool_library())]
|
||||
return list(COMPOSITION_POOL_PRESETS) + pool_choices
|
||||
|
||||
|
||||
def location_theme_choices() -> list[str]:
|
||||
return list(THEMATIC_LOCATION_PRESETS)
|
||||
|
||||
|
||||
def location_pool_names_for_preset(preset: str) -> list[str]:
|
||||
scene_pools = load_scene_pool_library()
|
||||
preset = str(preset or "custom_only")
|
||||
if preset.startswith("pool:"):
|
||||
pool_name = preset.split(":", 1)[1].strip()
|
||||
return [pool_name] if pool_name in scene_pools else []
|
||||
selectors = LOCATION_POOL_PRESETS.get(preset, ())
|
||||
names: list[str] = []
|
||||
for selector in selectors:
|
||||
if selector == "*":
|
||||
_unique_extend(names, sorted(scene_pools))
|
||||
elif selector.endswith("_"):
|
||||
_unique_extend(names, sorted(name for name in scene_pools if name.startswith(selector)))
|
||||
elif selector in scene_pools:
|
||||
_unique_extend(names, [selector])
|
||||
return names
|
||||
|
||||
|
||||
def entry_prompt_text(value: Any) -> str:
|
||||
if isinstance(value, dict):
|
||||
return str(
|
||||
value.get("prompt")
|
||||
or value.get("template")
|
||||
or value.get("text")
|
||||
or value.get("description")
|
||||
or value.get("name")
|
||||
or ""
|
||||
).strip()
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def json_line_entries(line: str, field_name: str) -> list[Any] | None:
|
||||
line = line.strip()
|
||||
if not line or line[0] not in "[{":
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON line in {field_name}: {exc}") from exc
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
return [parsed]
|
||||
|
||||
|
||||
def normalize_custom_location_entry(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
entry = dict(value)
|
||||
prompt = entry_prompt_text(entry)
|
||||
if not prompt:
|
||||
raise ValueError(f"Custom location JSON entry is missing prompt/text/description/name: {value!r}")
|
||||
entry["slug"] = _slug(str(entry.get("slug") or entry.get("name") or prompt))
|
||||
entry["prompt"] = prompt
|
||||
return entry
|
||||
prompt = str(value or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("Custom location entry cannot be empty")
|
||||
return {"slug": _slug(prompt), "prompt": prompt}
|
||||
|
||||
|
||||
def custom_location_entries(custom_locations: str) -> list[dict[str, Any]]:
|
||||
entries: list[dict[str, Any]] = []
|
||||
for raw_line in str(custom_locations or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
json_entries = json_line_entries(line, "custom_locations")
|
||||
if json_entries is not None:
|
||||
entries.extend(normalize_custom_location_entry(entry) for entry in json_entries)
|
||||
continue
|
||||
slug = ""
|
||||
prompt = line
|
||||
if ":" in line:
|
||||
maybe_slug, maybe_prompt = line.split(":", 1)
|
||||
if maybe_slug.strip() and maybe_prompt.strip():
|
||||
slug = _slug(maybe_slug)
|
||||
prompt = maybe_prompt.strip()
|
||||
prompt = prompt.strip()
|
||||
if prompt:
|
||||
entries.append({"slug": slug or _slug(prompt), "prompt": prompt})
|
||||
return entries
|
||||
|
||||
|
||||
def scene_entries_for_pool_names(pool_names: list[str]) -> list[Any]:
|
||||
scene_pools = load_scene_pool_library()
|
||||
entries: list[Any] = []
|
||||
for pool_name in pool_names:
|
||||
if pool_name not in scene_pools:
|
||||
continue
|
||||
_unique_extend(entries, scene_pools[pool_name])
|
||||
return entries
|
||||
|
||||
|
||||
def build_location_pool_json(
|
||||
enabled: bool = True,
|
||||
combine_mode: str = "replace",
|
||||
preset: str = "custom_only",
|
||||
custom_locations: str = "",
|
||||
location_config: str | dict[str, Any] | None = "",
|
||||
) -> str:
|
||||
incoming = parse_location_config(location_config)
|
||||
combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace"
|
||||
pool_names = location_pool_names_for_preset(preset)
|
||||
entries = scene_entries_for_pool_names(pool_names)
|
||||
_unique_extend(entries, custom_location_entries(custom_locations))
|
||||
|
||||
if combine_mode == "add" and incoming.get("enabled"):
|
||||
apply_mode = str(incoming.get("apply_mode") or "replace")
|
||||
merged_pool_names = _list_from(incoming.get("pool_names"))
|
||||
_unique_extend(merged_pool_names, pool_names)
|
||||
merged_entries = _list_from(incoming.get("scene_entries"))
|
||||
_unique_extend(merged_entries, entries)
|
||||
else:
|
||||
apply_mode = "replace" if combine_mode == "replace" else "add"
|
||||
merged_pool_names = pool_names
|
||||
merged_entries = entries
|
||||
|
||||
active = bool(enabled) and bool(merged_entries)
|
||||
theme = str(incoming.get("theme") or "") if combine_mode == "add" and incoming.get("enabled") else ""
|
||||
summary = (
|
||||
f"{apply_mode}; pools={len(merged_pool_names)}; locations={len(merged_entries)}"
|
||||
if active
|
||||
else "disabled or empty"
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"enabled": active,
|
||||
"apply_mode": apply_mode,
|
||||
"pool_names": merged_pool_names,
|
||||
"scene_entries": merged_entries,
|
||||
"summary": summary,
|
||||
"theme": theme,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def parse_location_config(location_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not location_config:
|
||||
return {"enabled": False, "apply_mode": "replace", "pool_names": [], "scene_entries": [], "theme": ""}
|
||||
if isinstance(location_config, dict):
|
||||
raw = dict(location_config)
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(location_config))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid location_config JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("location_config must be a JSON object")
|
||||
entries = _list_from(raw.get("scene_entries"))
|
||||
if not entries and raw.get("pool_names"):
|
||||
entries = scene_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))])
|
||||
return {
|
||||
"enabled": bool(raw.get("enabled")) and bool(entries),
|
||||
"apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace",
|
||||
"pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()],
|
||||
"scene_entries": entries,
|
||||
"summary": str(raw.get("summary") or ""),
|
||||
"theme": str(raw.get("theme") or ""),
|
||||
}
|
||||
|
||||
|
||||
def location_config_active(location_config: dict[str, Any]) -> bool:
|
||||
return bool(location_config.get("enabled")) and bool(location_config.get("scene_entries"))
|
||||
|
||||
|
||||
def composition_pool_names_for_preset(preset: str) -> list[str]:
|
||||
composition_pools = load_composition_pool_library()
|
||||
preset = str(preset or "custom_only")
|
||||
if preset.startswith("pool:"):
|
||||
pool_name = preset.split(":", 1)[1].strip()
|
||||
return [pool_name] if pool_name in composition_pools else []
|
||||
selectors = COMPOSITION_POOL_PRESETS.get(preset, ())
|
||||
names: list[str] = []
|
||||
for selector in selectors:
|
||||
if selector == "*":
|
||||
_unique_extend(names, sorted(composition_pools))
|
||||
elif selector.endswith("_"):
|
||||
_unique_extend(names, sorted(name for name in composition_pools if name.startswith(selector)))
|
||||
elif selector in composition_pools:
|
||||
_unique_extend(names, [selector])
|
||||
return names
|
||||
|
||||
|
||||
def normalize_custom_composition_entry(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
entry = dict(value)
|
||||
prompt = entry_prompt_text(entry)
|
||||
if not prompt:
|
||||
raise ValueError(f"Custom composition JSON entry is missing prompt/text/description/name: {value!r}")
|
||||
entry["prompt"] = prompt
|
||||
return entry
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError("Custom composition entry cannot be empty")
|
||||
return text
|
||||
|
||||
|
||||
def custom_composition_entries(custom_compositions: str) -> list[Any]:
|
||||
entries: list[Any] = []
|
||||
for raw_line in str(custom_compositions or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
json_entries = json_line_entries(line, "custom_compositions")
|
||||
if json_entries is not None:
|
||||
entries.extend(normalize_custom_composition_entry(entry) for entry in json_entries)
|
||||
continue
|
||||
entries.append(line)
|
||||
return entries
|
||||
|
||||
|
||||
def composition_entries_for_pool_names(pool_names: list[str]) -> list[Any]:
|
||||
composition_pools = load_composition_pool_library()
|
||||
entries: list[Any] = []
|
||||
for pool_name in pool_names:
|
||||
if pool_name not in composition_pools:
|
||||
continue
|
||||
_unique_extend(entries, composition_pools[pool_name])
|
||||
return entries
|
||||
|
||||
|
||||
def build_composition_pool_json(
|
||||
enabled: bool = True,
|
||||
combine_mode: str = "replace",
|
||||
preset: str = "custom_only",
|
||||
custom_compositions: str = "",
|
||||
composition_config: str | dict[str, Any] | None = "",
|
||||
) -> str:
|
||||
incoming = parse_composition_config(composition_config)
|
||||
combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace"
|
||||
pool_names = composition_pool_names_for_preset(preset)
|
||||
entries = composition_entries_for_pool_names(pool_names)
|
||||
_unique_extend(entries, COMPOSITION_INLINE_PRESETS.get(str(preset or ""), []))
|
||||
_unique_extend(entries, custom_composition_entries(custom_compositions))
|
||||
|
||||
if combine_mode == "add" and incoming.get("enabled"):
|
||||
apply_mode = str(incoming.get("apply_mode") or "replace")
|
||||
merged_pool_names = _list_from(incoming.get("pool_names"))
|
||||
_unique_extend(merged_pool_names, pool_names)
|
||||
merged_entries = _list_from(incoming.get("composition_entries"))
|
||||
_unique_extend(merged_entries, entries)
|
||||
else:
|
||||
apply_mode = "replace" if combine_mode == "replace" else "add"
|
||||
merged_pool_names = pool_names
|
||||
merged_entries = entries
|
||||
|
||||
active = bool(enabled) and bool(merged_entries)
|
||||
theme = str(incoming.get("theme") or "") if combine_mode == "add" and incoming.get("enabled") else ""
|
||||
summary = (
|
||||
f"{apply_mode}; pools={len(merged_pool_names)}; compositions={len(merged_entries)}"
|
||||
if active
|
||||
else "disabled or empty"
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"enabled": active,
|
||||
"apply_mode": apply_mode,
|
||||
"pool_names": merged_pool_names,
|
||||
"composition_entries": merged_entries,
|
||||
"summary": summary,
|
||||
"theme": theme,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def parse_composition_config(composition_config: str | dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not composition_config:
|
||||
return {"enabled": False, "apply_mode": "replace", "pool_names": [], "composition_entries": [], "theme": ""}
|
||||
if isinstance(composition_config, dict):
|
||||
raw = dict(composition_config)
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(composition_config))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid composition_config JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("composition_config must be a JSON object")
|
||||
entries = _list_from(raw.get("composition_entries"))
|
||||
if not entries and raw.get("pool_names"):
|
||||
entries = composition_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))])
|
||||
return {
|
||||
"enabled": bool(raw.get("enabled")) and bool(entries),
|
||||
"apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace",
|
||||
"pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()],
|
||||
"composition_entries": entries,
|
||||
"summary": str(raw.get("summary") or ""),
|
||||
"theme": str(raw.get("theme") or ""),
|
||||
}
|
||||
|
||||
|
||||
def composition_config_active(composition_config: dict[str, Any]) -> bool:
|
||||
return bool(composition_config.get("enabled")) and bool(composition_config.get("composition_entries"))
|
||||
|
||||
|
||||
def build_thematic_location_json(
|
||||
enabled: bool = True,
|
||||
combine_mode: str = "replace",
|
||||
theme: str = "semi_public_affair",
|
||||
custom_locations: str = "",
|
||||
custom_compositions: str = "",
|
||||
location_config: str | dict[str, Any] | None = "",
|
||||
composition_config: str | dict[str, Any] | None = "",
|
||||
) -> tuple[str, str, str]:
|
||||
theme_data = THEMATIC_LOCATION_PRESETS.get(str(theme or ""), THEMATIC_LOCATION_PRESETS["semi_public_affair"])
|
||||
location_lines = "\n".join(
|
||||
f"{entry['slug']}: {entry['prompt']}"
|
||||
for entry in theme_data.get("locations", [])
|
||||
if isinstance(entry, dict) and entry.get("slug") and entry.get("prompt")
|
||||
)
|
||||
if custom_locations.strip():
|
||||
location_lines = "\n".join(part for part in (location_lines, custom_locations.strip()) if part)
|
||||
composition_lines = "\n".join(str(entry) for entry in theme_data.get("compositions", []) if str(entry).strip())
|
||||
if custom_compositions.strip():
|
||||
composition_lines = "\n".join(part for part in (composition_lines, custom_compositions.strip()) if part)
|
||||
resolved_location_config = build_location_pool_json(
|
||||
enabled=enabled,
|
||||
combine_mode=combine_mode,
|
||||
preset="custom_only",
|
||||
custom_locations=location_lines,
|
||||
location_config=location_config or "",
|
||||
)
|
||||
resolved_composition_config = build_composition_pool_json(
|
||||
enabled=enabled,
|
||||
combine_mode=combine_mode,
|
||||
preset="custom_only",
|
||||
custom_compositions=composition_lines,
|
||||
composition_config=composition_config or "",
|
||||
)
|
||||
location_payload = json.loads(resolved_location_config)
|
||||
composition_payload = json.loads(resolved_composition_config)
|
||||
location_payload["theme"] = str(theme or "")
|
||||
composition_payload["theme"] = str(theme or "")
|
||||
themed_scene_entries = []
|
||||
for entry in location_payload.get("scene_entries") or []:
|
||||
if isinstance(entry, dict):
|
||||
themed_entry = dict(entry)
|
||||
themed_entry.setdefault("theme", str(theme or ""))
|
||||
themed_scene_entries.append(themed_entry)
|
||||
else:
|
||||
themed_scene_entries.append(entry)
|
||||
location_payload["scene_entries"] = themed_scene_entries
|
||||
resolved_location_config = json.dumps(location_payload, ensure_ascii=True, sort_keys=True)
|
||||
resolved_composition_config = json.dumps(composition_payload, ensure_ascii=True, sort_keys=True)
|
||||
location_summary = location_payload.get("summary", "")
|
||||
composition_summary = composition_payload.get("summary", "")
|
||||
summary = f"{theme}; locations={location_summary}; compositions={composition_summary}"
|
||||
return resolved_location_config, resolved_composition_config, summary
|
||||
|
||||
|
||||
_location_pool_names_for_preset = location_pool_names_for_preset
|
||||
_custom_location_entries = custom_location_entries
|
||||
_scene_entries_for_pool_names = scene_entries_for_pool_names
|
||||
_parse_location_config = parse_location_config
|
||||
_location_config_active = location_config_active
|
||||
_composition_pool_names_for_preset = composition_pool_names_for_preset
|
||||
_custom_composition_entries = custom_composition_entries
|
||||
_composition_entries_for_pool_names = composition_entries_for_pool_names
|
||||
_parse_composition_config = parse_composition_config
|
||||
_composition_config_active = composition_config_active
|
||||
+18
-74
@@ -6,6 +6,11 @@ import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import index_switch_policy
|
||||
except Exception: # Allows local imports outside ComfyUI package mode.
|
||||
import index_switch_policy
|
||||
|
||||
try:
|
||||
from comfy_execution.graph import ExecutionBlocker
|
||||
from comfy_execution.graph_utils import GraphBuilder, is_link
|
||||
@@ -41,16 +46,16 @@ except Exception:
|
||||
|
||||
MAX_LOOP_VALUES = 20
|
||||
MAX_CARRY_VALUES = MAX_LOOP_VALUES - 2
|
||||
MAX_SWITCH_INPUTS = 64
|
||||
MAX_SWITCH_INPUTS = index_switch_policy.MAX_SWITCH_INPUTS
|
||||
COLLECTION_MODES = ["auto_batch", "list", "image_batch", "latent_batch", "string_lines"]
|
||||
ACCUMULATOR_ACTIONS = ["append_variant", "replace_by_entry_id", "append", "clear_then_append", "clear", "read"]
|
||||
ACCUMULATOR_IMAGE_BATCH_MODES = ["same_size_only", "resize_to_first"]
|
||||
ACCUMULATOR_IMAGE_GROUPS = 4
|
||||
ACCUMULATOR_PREVIEW_VIEW_MODES = ["grid", "carousel"]
|
||||
ACCUMULATOR_PREVIEW_DELETE_ACTIONS = ["none", "delete_entry_id", "delete_index", "clear"]
|
||||
INDEX_SWITCH_MODES = ["pick_input", "route_output"]
|
||||
INDEX_SWITCH_BASES = ["one_based", "zero_based"]
|
||||
INDEX_SWITCH_MISSING_BEHAVIORS = ["fallback", "none", "clamp", "wrap"]
|
||||
INDEX_SWITCH_MODES = index_switch_policy.INDEX_SWITCH_MODES
|
||||
INDEX_SWITCH_BASES = index_switch_policy.INDEX_SWITCH_BASES
|
||||
INDEX_SWITCH_MISSING_BEHAVIORS = index_switch_policy.INDEX_SWITCH_MISSING_BEHAVIORS
|
||||
PREVIEW_TEXT_FORMATS = ["auto", "json", "repr", "str"]
|
||||
|
||||
_ACCUMULATOR_STORES: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -629,44 +634,6 @@ def append_collected_value(collection: Any, value: Any, mode: str = "auto_batch"
|
||||
return _as_list(collection) + [value]
|
||||
|
||||
|
||||
def _switch_available_indices(kwargs: dict[str, Any]) -> list[int]:
|
||||
indices = []
|
||||
for key in kwargs:
|
||||
match = re.match(r"^input_(\d+)$", str(key))
|
||||
if match:
|
||||
indices.append(int(match.group(1)))
|
||||
return sorted(set(indices))
|
||||
|
||||
|
||||
def _switch_requested_index(index: Any, index_base: str) -> int:
|
||||
requested = int(index)
|
||||
return requested + 1 if index_base == "zero_based" else requested
|
||||
|
||||
|
||||
def _switch_resolved_index(requested: int, available: list[int], missing_behavior: str) -> int | None:
|
||||
if requested in available:
|
||||
return requested
|
||||
if missing_behavior in ("fallback", "none") or not available:
|
||||
return None
|
||||
if missing_behavior == "wrap":
|
||||
return available[(requested - 1) % len(available)]
|
||||
if requested <= available[0]:
|
||||
return available[0]
|
||||
if requested >= available[-1]:
|
||||
return available[-1]
|
||||
lower = [value for value in available if value <= requested]
|
||||
return lower[-1] if lower else available[0]
|
||||
|
||||
|
||||
def _switch_status(requested: int, selected: int | None, used_fallback: bool, available: list[int]) -> str:
|
||||
available_text = ",".join(str(index) for index in available) or "none"
|
||||
if used_fallback:
|
||||
return f"requested=input_{requested}; selected=fallback; available={available_text}"
|
||||
if selected is None:
|
||||
return f"requested=input_{requested}; selected=none; available={available_text}"
|
||||
return f"requested=input_{requested}; selected=input_{selected}; available={available_text}"
|
||||
|
||||
|
||||
class SxCPWhileLoopStart:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -923,50 +890,27 @@ class SxCPIndexSwitch:
|
||||
missing_behavior: str,
|
||||
kwargs: dict[str, Any],
|
||||
) -> tuple[int, int | None, list[int]]:
|
||||
index_base = index_base if index_base in INDEX_SWITCH_BASES else "one_based"
|
||||
missing_behavior = missing_behavior if missing_behavior in INDEX_SWITCH_MISSING_BEHAVIORS else "fallback"
|
||||
requested = _switch_requested_index(index, index_base)
|
||||
available = _switch_available_indices(kwargs)
|
||||
selected = _switch_resolved_index(requested, available, missing_behavior)
|
||||
return requested, selected, available
|
||||
return index_switch_policy.input_selection(index, index_base, missing_behavior, kwargs)
|
||||
|
||||
def _route_selection(self, index: Any, index_base: str, missing_behavior: str) -> tuple[int, int | None]:
|
||||
index_base = index_base if index_base in INDEX_SWITCH_BASES else "one_based"
|
||||
missing_behavior = missing_behavior if missing_behavior in INDEX_SWITCH_MISSING_BEHAVIORS else "fallback"
|
||||
requested = _switch_requested_index(index, index_base)
|
||||
if 1 <= requested <= MAX_SWITCH_INPUTS:
|
||||
return requested, requested
|
||||
if missing_behavior == "wrap":
|
||||
return requested, ((requested - 1) % MAX_SWITCH_INPUTS) + 1
|
||||
if missing_behavior == "clamp":
|
||||
return requested, min(max(requested, 1), MAX_SWITCH_INPUTS)
|
||||
return requested, None
|
||||
return index_switch_policy.route_selection(index, index_base, missing_behavior, MAX_SWITCH_INPUTS)
|
||||
|
||||
def _blocked_outputs(self) -> list[Any]:
|
||||
return [_execution_blocker() for _index in range(MAX_SWITCH_INPUTS)]
|
||||
|
||||
def check_lazy_status(self, index, mode, index_base, missing_behavior, **kwargs):
|
||||
mode = mode if mode in INDEX_SWITCH_MODES else "pick_input"
|
||||
if mode == "route_output":
|
||||
return ["route_value"] if "route_value" in kwargs else []
|
||||
requested, selected, _available = self._input_selection(index, index_base, missing_behavior, kwargs)
|
||||
selected_name = f"input_{selected}" if selected is not None else f"input_{requested}"
|
||||
if selected_name in kwargs:
|
||||
return [selected_name]
|
||||
if missing_behavior == "fallback" and "fallback" in kwargs:
|
||||
return ["fallback"]
|
||||
return []
|
||||
return index_switch_policy.lazy_inputs(index, mode, index_base, missing_behavior, kwargs)
|
||||
|
||||
def switch(self, index, mode, index_base, missing_behavior, **kwargs):
|
||||
mode = mode if mode in INDEX_SWITCH_MODES else "pick_input"
|
||||
missing_behavior = missing_behavior if missing_behavior in INDEX_SWITCH_MISSING_BEHAVIORS else "fallback"
|
||||
mode = index_switch_policy.normalize_mode(mode)
|
||||
missing_behavior = index_switch_policy.normalize_missing_behavior(missing_behavior)
|
||||
if mode == "route_output":
|
||||
requested, selected = self._route_selection(index, index_base, missing_behavior)
|
||||
value = kwargs.get("route_value")
|
||||
outputs = self._blocked_outputs()
|
||||
if selected is not None and "route_value" in kwargs:
|
||||
outputs[selected - 1] = value
|
||||
status = f"mode=route_output; requested=output_{requested}; selected={'none' if selected is None else f'output_{selected}'}; range=1-{MAX_SWITCH_INPUTS}"
|
||||
status = f"mode=route_output; {index_switch_policy.route_status(requested, selected, MAX_SWITCH_INPUTS)}"
|
||||
selected_index = selected or 0
|
||||
return tuple([value if "route_value" in kwargs else None, selected_index, status] + outputs)
|
||||
|
||||
@@ -975,12 +919,12 @@ class SxCPIndexSwitch:
|
||||
selected_name = f"input_{selected}"
|
||||
if selected_name in kwargs:
|
||||
value = kwargs.get(selected_name)
|
||||
status = f"mode=pick_input; {_switch_status(requested, selected, False, available)}"
|
||||
status = f"mode=pick_input; {index_switch_policy.input_status(requested, selected, False, available)}"
|
||||
return tuple([value, selected, status] + self._blocked_outputs())
|
||||
if missing_behavior == "fallback" and "fallback" in kwargs:
|
||||
status = f"mode=pick_input; {_switch_status(requested, None, True, available)}"
|
||||
status = f"mode=pick_input; {index_switch_policy.input_status(requested, None, True, available)}"
|
||||
return tuple([kwargs.get("fallback"), 0, status] + self._blocked_outputs())
|
||||
status = f"mode=pick_input; {_switch_status(requested, None, False, available)}"
|
||||
status = f"mode=pick_input; {index_switch_policy.input_status(requested, None, False, available)}"
|
||||
return tuple([None, 0, status] + self._blocked_outputs())
|
||||
|
||||
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from .prompt_builder import (
|
||||
build_prompt,
|
||||
build_prompt_from_configs,
|
||||
category_choices,
|
||||
ethnicity_choices,
|
||||
subcategory_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from prompt_builder import (
|
||||
build_prompt,
|
||||
build_prompt_from_configs,
|
||||
category_choices,
|
||||
ethnicity_choices,
|
||||
subcategory_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST"
|
||||
SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG"
|
||||
SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG"
|
||||
SXCP_CAMERA_CONFIG = "SXCP_CAMERA_CONFIG"
|
||||
SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG"
|
||||
SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG"
|
||||
SXCP_CATEGORY_CONFIG = "SXCP_CATEGORY_CONFIG"
|
||||
SXCP_CAST_CONFIG = "SXCP_CAST_CONFIG"
|
||||
SXCP_GENERATION_PROFILE = "SXCP_GENERATION_PROFILE"
|
||||
SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG"
|
||||
SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST"
|
||||
SXCP_CHARACTER_PROFILE = "SXCP_CHARACTER_PROFILE"
|
||||
|
||||
|
||||
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": (["random", "full", "minimal"], {"default": "random"}),
|
||||
"ethnicity": (ethnicity_choices(), {"default": "any"}),
|
||||
"poses": (["random", "standard", "evocative"], {"default": "random"}),
|
||||
"expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"backside_bias": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"figure": (["random", "curvy", "balanced", "bombshell"], {"default": "random"}),
|
||||
"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": {
|
||||
"ethnicity_list": (SXCP_ETHNICITY_LIST,),
|
||||
"seed_config": (SXCP_SEED_CONFIG,),
|
||||
"camera_config": (SXCP_CAMERA_CONFIG,),
|
||||
"location_config": (SXCP_LOCATION_CONFIG,),
|
||||
"composition_config": (SXCP_COMPOSITION_CONFIG,),
|
||||
"character_profile": (SXCP_CHARACTER_PROFILE,),
|
||||
"character_cast": (SXCP_CHARACTER_CAST,),
|
||||
"hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,),
|
||||
"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,
|
||||
expression_enabled,
|
||||
expression_intensity,
|
||||
backside_bias,
|
||||
figure,
|
||||
women_count,
|
||||
men_count,
|
||||
minimal_clothing_ratio,
|
||||
standard_pose_ratio,
|
||||
trigger,
|
||||
prepend_trigger_to_prompt,
|
||||
seed_config="",
|
||||
camera_config="",
|
||||
location_config="",
|
||||
composition_config="",
|
||||
character_profile="",
|
||||
character_cast="",
|
||||
hardcore_position_config="",
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
no_plus_women=False,
|
||||
no_black=False,
|
||||
ethnicity_list="",
|
||||
):
|
||||
row = build_prompt(
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
clothing=clothing,
|
||||
ethnicity=ethnicity_list or ethnicity,
|
||||
poses=poses,
|
||||
expression_enabled=expression_enabled,
|
||||
expression_intensity=expression_intensity,
|
||||
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 "",
|
||||
camera_config=camera_config or "",
|
||||
location_config=location_config or "",
|
||||
composition_config=composition_config or "",
|
||||
character_profile=character_profile or "",
|
||||
character_cast=character_cast or "",
|
||||
hardcore_position_config=hardcore_position_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 SxCPPromptBuilderFromConfigs:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"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}),
|
||||
},
|
||||
"optional": {
|
||||
"category_config": (SXCP_CATEGORY_CONFIG,),
|
||||
"cast_config": (SXCP_CAST_CONFIG,),
|
||||
"generation_profile": (SXCP_GENERATION_PROFILE,),
|
||||
"filter_config": (SXCP_FILTER_CONFIG,),
|
||||
"ethnicity_list": (SXCP_ETHNICITY_LIST,),
|
||||
"seed_config": (SXCP_SEED_CONFIG,),
|
||||
"camera_config": (SXCP_CAMERA_CONFIG,),
|
||||
"location_config": (SXCP_LOCATION_CONFIG,),
|
||||
"composition_config": (SXCP_COMPOSITION_CONFIG,),
|
||||
"character_profile": (SXCP_CHARACTER_PROFILE,),
|
||||
"character_cast": (SXCP_CHARACTER_CAST,),
|
||||
"hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,),
|
||||
"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,
|
||||
row_number,
|
||||
start_index,
|
||||
seed,
|
||||
category_config="",
|
||||
cast_config="",
|
||||
generation_profile="",
|
||||
filter_config="",
|
||||
ethnicity_list="",
|
||||
seed_config="",
|
||||
camera_config="",
|
||||
location_config="",
|
||||
composition_config="",
|
||||
character_profile="",
|
||||
character_cast="",
|
||||
hardcore_position_config="",
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
):
|
||||
row = build_prompt_from_configs(
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
category_config=category_config or "",
|
||||
cast_config=cast_config or "",
|
||||
generation_profile=generation_profile or "",
|
||||
filter_config=ethnicity_list or filter_config or "",
|
||||
seed_config=seed_config or "",
|
||||
camera_config=camera_config or "",
|
||||
location_config=location_config or "",
|
||||
composition_config=composition_config or "",
|
||||
character_profile=character_profile or "",
|
||||
character_cast=character_cast or "",
|
||||
hardcore_position_config=hardcore_position_config or "",
|
||||
extra_positive=extra_positive or "",
|
||||
extra_negative=extra_negative or "",
|
||||
)
|
||||
return (
|
||||
row["prompt"],
|
||||
row["negative_prompt"],
|
||||
row["caption"],
|
||||
json.dumps(row, ensure_ascii=True, sort_keys=True),
|
||||
row.get("main_category", ""),
|
||||
row.get("subcategory", ""),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPPromptBuilder": SxCPPromptBuilder,
|
||||
"SxCPPromptBuilderFromConfigs": SxCPPromptBuilderFromConfigs,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPPromptBuilder": "SxCP Prompt Builder",
|
||||
"SxCPPromptBuilderFromConfigs": "SxCP Prompt Builder From Configs",
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from .loop_nodes import ANY_TYPE
|
||||
from .camera_config import (
|
||||
build_camera_config_json,
|
||||
build_camera_orbit_config_json,
|
||||
build_qwen_camera_config_json,
|
||||
camera_angle_choices,
|
||||
camera_detail_choices,
|
||||
camera_distance_choices,
|
||||
camera_lens_choices,
|
||||
camera_mode_choices,
|
||||
camera_orbit_focus_choices,
|
||||
camera_orbit_framing_choices,
|
||||
camera_orientation_choices,
|
||||
camera_phone_choices,
|
||||
camera_priority_choices,
|
||||
camera_shot_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from loop_nodes import ANY_TYPE
|
||||
from camera_config import (
|
||||
build_camera_config_json,
|
||||
build_camera_orbit_config_json,
|
||||
build_qwen_camera_config_json,
|
||||
camera_angle_choices,
|
||||
camera_detail_choices,
|
||||
camera_distance_choices,
|
||||
camera_lens_choices,
|
||||
camera_mode_choices,
|
||||
camera_orbit_focus_choices,
|
||||
camera_orbit_framing_choices,
|
||||
camera_orientation_choices,
|
||||
camera_phone_choices,
|
||||
camera_priority_choices,
|
||||
camera_shot_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_CAMERA_CONFIG = "SXCP_CAMERA_CONFIG"
|
||||
|
||||
|
||||
class SxCPCameraControl:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"camera_mode": (camera_mode_choices(), {"default": "handheld_selfie"}),
|
||||
"shot_size": (camera_shot_choices(), {"default": "auto"}),
|
||||
"angle": (camera_angle_choices(), {"default": "auto"}),
|
||||
"lens": (camera_lens_choices(), {"default": "smartphone_wide"}),
|
||||
"distance": (camera_distance_choices(), {"default": "arm_length"}),
|
||||
"orientation": (camera_orientation_choices(), {"default": "vertical_story"}),
|
||||
"phone_visibility": (camera_phone_choices(), {"default": "phone_visible"}),
|
||||
"priority": (camera_priority_choices(), {"default": "locked"}),
|
||||
"camera_detail": (camera_detail_choices(), {"default": "compact"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CAMERA_CONFIG,)
|
||||
RETURN_NAMES = ("camera_config",)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
camera_mode,
|
||||
shot_size,
|
||||
angle,
|
||||
lens,
|
||||
distance,
|
||||
orientation,
|
||||
phone_visibility,
|
||||
priority,
|
||||
camera_detail,
|
||||
):
|
||||
return (
|
||||
build_camera_config_json(
|
||||
camera_mode=camera_mode,
|
||||
shot_size=shot_size,
|
||||
angle=angle,
|
||||
lens=lens,
|
||||
distance=distance,
|
||||
orientation=orientation,
|
||||
phone_visibility=phone_visibility,
|
||||
priority=priority,
|
||||
camera_detail=camera_detail,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SxCPCameraOrbitControl:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"camera_mode": (camera_mode_choices(), {"default": "standard"}),
|
||||
"horizontal_angle": ("INT", {"default": 0, "min": 0, "max": 359, "step": 1}),
|
||||
"vertical_angle": ("INT", {"default": 0, "min": -90, "max": 90, "step": 1}),
|
||||
"zoom": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 10.0, "step": 0.1}),
|
||||
"framing": (camera_orbit_framing_choices(), {"default": "from_zoom"}),
|
||||
"subject_focus": (camera_orbit_focus_choices(), {"default": "auto"}),
|
||||
"lens": (camera_lens_choices(), {"default": "auto"}),
|
||||
"orientation": (camera_orientation_choices(), {"default": "auto"}),
|
||||
"phone_visibility": (camera_phone_choices(), {"default": "auto"}),
|
||||
"priority": (camera_priority_choices(), {"default": "locked"}),
|
||||
"camera_detail": (camera_detail_choices(), {"default": "compact"}),
|
||||
"include_degrees": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CAMERA_CONFIG, "STRING", "STRING")
|
||||
RETURN_NAMES = ("camera_config", "camera_prompt", "camera_info_json")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
camera_mode,
|
||||
horizontal_angle,
|
||||
vertical_angle,
|
||||
zoom,
|
||||
framing,
|
||||
subject_focus,
|
||||
lens,
|
||||
orientation,
|
||||
phone_visibility,
|
||||
priority,
|
||||
camera_detail,
|
||||
include_degrees,
|
||||
):
|
||||
config = build_camera_orbit_config_json(
|
||||
enabled=enabled,
|
||||
camera_mode=camera_mode,
|
||||
horizontal_angle=horizontal_angle,
|
||||
vertical_angle=vertical_angle,
|
||||
zoom=zoom,
|
||||
framing=framing,
|
||||
subject_focus=subject_focus,
|
||||
lens=lens,
|
||||
orientation=orientation,
|
||||
phone_visibility=phone_visibility,
|
||||
priority=priority,
|
||||
camera_detail=camera_detail,
|
||||
include_degrees=include_degrees,
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
camera_prompt = parsed.get("custom_camera_prompt", "")
|
||||
return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
class SxCPQwenCameraTranslator:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"qwen_prompt": ("STRING", {"default": ""}),
|
||||
"prefer_camera_info": ("BOOLEAN", {"default": True}),
|
||||
"camera_mode": (camera_mode_choices(), {"default": "standard"}),
|
||||
"subject_focus": (camera_orbit_focus_choices(), {"default": "auto"}),
|
||||
"lens": (camera_lens_choices(), {"default": "auto"}),
|
||||
"orientation": (camera_orientation_choices(), {"default": "auto"}),
|
||||
"phone_visibility": (camera_phone_choices(), {"default": "auto"}),
|
||||
"priority": (camera_priority_choices(), {"default": "locked"}),
|
||||
"camera_detail": (camera_detail_choices(), {"default": "compact"}),
|
||||
"include_degrees": ("BOOLEAN", {"default": False}),
|
||||
"suppress_phone_visibility": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"camera_info": (ANY_TYPE,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CAMERA_CONFIG, "STRING", "STRING")
|
||||
RETURN_NAMES = ("camera_config", "camera_prompt", "camera_info_json")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
qwen_prompt,
|
||||
prefer_camera_info,
|
||||
camera_mode,
|
||||
subject_focus,
|
||||
lens,
|
||||
orientation,
|
||||
phone_visibility,
|
||||
priority,
|
||||
camera_detail,
|
||||
include_degrees,
|
||||
suppress_phone_visibility,
|
||||
camera_info=None,
|
||||
):
|
||||
config = build_qwen_camera_config_json(
|
||||
qwen_prompt=qwen_prompt or "",
|
||||
camera_info=camera_info,
|
||||
prefer_camera_info=prefer_camera_info,
|
||||
camera_mode=camera_mode,
|
||||
subject_focus=subject_focus,
|
||||
lens=lens,
|
||||
orientation=orientation,
|
||||
phone_visibility=phone_visibility,
|
||||
priority=priority,
|
||||
camera_detail=camera_detail,
|
||||
include_degrees=include_degrees,
|
||||
suppress_phone_visibility=suppress_phone_visibility,
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
camera_prompt = parsed.get("custom_camera_prompt", "")
|
||||
return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPCameraControl": SxCPCameraControl,
|
||||
"SxCPCameraOrbitControl": SxCPCameraOrbitControl,
|
||||
"SxCPQwenCameraTranslator": SxCPQwenCameraTranslator,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPCameraControl": "SxCP Camera Control",
|
||||
"SxCPCameraOrbitControl": "SxCP Camera Orbit Control",
|
||||
"SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator",
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from .character_config import (
|
||||
build_characteristics_config_json,
|
||||
build_hair_config_json,
|
||||
character_age_choices,
|
||||
character_body_choices,
|
||||
character_descriptor_detail_choices,
|
||||
character_eye_color_choices,
|
||||
character_figure_choices,
|
||||
character_hair_color_choices,
|
||||
character_hair_length_choices,
|
||||
character_hair_style_choices,
|
||||
character_label_choices,
|
||||
character_man_body_choices,
|
||||
character_presence_choices,
|
||||
character_woman_body_choices,
|
||||
)
|
||||
from .character_profile import (
|
||||
build_character_manual_config_json,
|
||||
character_profile_choices,
|
||||
load_character_profile_json,
|
||||
)
|
||||
from .character_slot import build_character_slot_json
|
||||
from .prompt_builder import (
|
||||
build_character_profile_json,
|
||||
character_ethnicity_choices,
|
||||
character_hardcore_clothing_state_choices,
|
||||
character_hardcore_clothing_values,
|
||||
character_softcore_outfit_source_choices,
|
||||
character_softcore_outfit_values,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from character_config import (
|
||||
build_characteristics_config_json,
|
||||
build_hair_config_json,
|
||||
character_age_choices,
|
||||
character_body_choices,
|
||||
character_descriptor_detail_choices,
|
||||
character_eye_color_choices,
|
||||
character_figure_choices,
|
||||
character_hair_color_choices,
|
||||
character_hair_length_choices,
|
||||
character_hair_style_choices,
|
||||
character_label_choices,
|
||||
character_man_body_choices,
|
||||
character_presence_choices,
|
||||
character_woman_body_choices,
|
||||
)
|
||||
from character_profile import (
|
||||
build_character_manual_config_json,
|
||||
character_profile_choices,
|
||||
load_character_profile_json,
|
||||
)
|
||||
from character_slot import build_character_slot_json
|
||||
from prompt_builder import (
|
||||
build_character_profile_json,
|
||||
character_ethnicity_choices,
|
||||
character_hardcore_clothing_state_choices,
|
||||
character_hardcore_clothing_values,
|
||||
character_softcore_outfit_source_choices,
|
||||
character_softcore_outfit_values,
|
||||
)
|
||||
|
||||
|
||||
SXCP_HAIR_CONFIG = "SXCP_HAIR_CONFIG"
|
||||
SXCP_CHARACTERISTICS = "SXCP_CHARACTERISTICS"
|
||||
SXCP_CHARACTER_MANUAL = "SXCP_CHARACTER_MANUAL"
|
||||
SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST"
|
||||
SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST"
|
||||
SXCP_CHARACTER_SLOT = "SXCP_CHARACTER_SLOT"
|
||||
SXCP_CHARACTER_PROFILE = "SXCP_CHARACTER_PROFILE"
|
||||
|
||||
|
||||
class _SxCPHairAxisNode:
|
||||
AXIS = "color"
|
||||
PREFIX = "include"
|
||||
|
||||
@classmethod
|
||||
def _choices(cls):
|
||||
if cls.AXIS == "color":
|
||||
return [choice for choice in character_hair_color_choices() if choice != "random"]
|
||||
if cls.AXIS == "length":
|
||||
return [choice for choice in character_hair_length_choices() if choice != "random"]
|
||||
return [choice for choice in character_hair_style_choices() if choice != "random"]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
required = {
|
||||
"combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}),
|
||||
}
|
||||
for choice in cls._choices():
|
||||
required[f"{cls.PREFIX}_{choice}"] = ("BOOLEAN", {"default": False})
|
||||
return {
|
||||
"required": required,
|
||||
"optional": {
|
||||
"hair_config": (SXCP_HAIR_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_HAIR_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("hair_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, combine_mode="replace_axis", hair_config="", **kwargs):
|
||||
selected = [
|
||||
choice
|
||||
for choice in self._choices()
|
||||
if bool(kwargs.get(f"{self.PREFIX}_{choice}", False))
|
||||
]
|
||||
config = build_hair_config_json(
|
||||
hair_config=hair_config or "",
|
||||
axis=self.AXIS,
|
||||
selected_values=selected,
|
||||
combine_mode=combine_mode,
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
return config, parsed.get("summary", "")
|
||||
|
||||
|
||||
class SxCPHairColor(_SxCPHairAxisNode):
|
||||
AXIS = "color"
|
||||
|
||||
|
||||
class SxCPHairLength(_SxCPHairAxisNode):
|
||||
AXIS = "length"
|
||||
|
||||
|
||||
class SxCPHairStyle(_SxCPHairAxisNode):
|
||||
AXIS = "style"
|
||||
|
||||
|
||||
def _choice_input_key(prefix, choice):
|
||||
key = "".join(char if char.isalnum() else "_" for char in str(choice).lower()).strip("_")
|
||||
while "__" in key:
|
||||
key = key.replace("__", "_")
|
||||
return f"{prefix}_{key}"
|
||||
|
||||
|
||||
class SxCPCharacterAgeRange:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}),
|
||||
"min_age": ("INT", {"default": 21, "min": 21, "max": 85, "step": 1}),
|
||||
"max_age": ("INT", {"default": 35, "min": 21, "max": 85, "step": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING")
|
||||
RETURN_NAMES = ("characteristics", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, combine_mode, min_age, max_age, characteristics=""):
|
||||
start = max(21, min(85, int(min_age)))
|
||||
end = max(21, min(85, int(max_age)))
|
||||
if end < start:
|
||||
start, end = end, start
|
||||
ages = [f"{age}-year-old adult" for age in range(start, end + 1)]
|
||||
config = build_characteristics_config_json(
|
||||
characteristics=characteristics or "",
|
||||
axis="ages",
|
||||
selected_values=ages,
|
||||
combine_mode=combine_mode,
|
||||
)
|
||||
return config, json.loads(config).get("summary", "")
|
||||
|
||||
|
||||
class _SxCPBodyPoolNode:
|
||||
SUBJECT = "character"
|
||||
|
||||
@classmethod
|
||||
def _choices(cls):
|
||||
if cls.SUBJECT == "woman":
|
||||
return [choice for choice in character_woman_body_choices() if choice not in ("random", "manual")]
|
||||
if cls.SUBJECT == "man":
|
||||
return [choice for choice in character_man_body_choices() if choice not in ("random", "manual")]
|
||||
return [choice for choice in character_body_choices() if choice not in ("random", "manual")]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
required = {
|
||||
"combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}),
|
||||
}
|
||||
for choice in cls._choices():
|
||||
required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False})
|
||||
return {
|
||||
"required": required,
|
||||
"optional": {
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING")
|
||||
RETURN_NAMES = ("characteristics", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, combine_mode="replace_axis", characteristics="", **kwargs):
|
||||
selected = [
|
||||
choice
|
||||
for choice in self._choices()
|
||||
if bool(kwargs.get(_choice_input_key("include", choice), False))
|
||||
]
|
||||
config = build_characteristics_config_json(
|
||||
characteristics=characteristics or "",
|
||||
axis="bodies",
|
||||
selected_values=selected,
|
||||
combine_mode=combine_mode,
|
||||
)
|
||||
return config, json.loads(config).get("summary", "")
|
||||
|
||||
|
||||
class SxCPCharacterBodyPool(_SxCPBodyPoolNode):
|
||||
SUBJECT = "character"
|
||||
|
||||
|
||||
class SxCPWomanBodyPool(_SxCPBodyPoolNode):
|
||||
SUBJECT = "woman"
|
||||
|
||||
|
||||
class SxCPManBodyPool(_SxCPBodyPoolNode):
|
||||
SUBJECT = "man"
|
||||
|
||||
|
||||
class SxCPEyeColorPool:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
required = {
|
||||
"combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}),
|
||||
}
|
||||
for choice in character_eye_color_choices():
|
||||
if choice != "random":
|
||||
required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False})
|
||||
return {
|
||||
"required": required,
|
||||
"optional": {
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING")
|
||||
RETURN_NAMES = ("characteristics", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, combine_mode="replace_axis", characteristics="", **kwargs):
|
||||
selected = [
|
||||
choice
|
||||
for choice in character_eye_color_choices()
|
||||
if choice != "random" and bool(kwargs.get(_choice_input_key("include", choice), False))
|
||||
]
|
||||
config = build_characteristics_config_json(
|
||||
characteristics=characteristics or "",
|
||||
axis="eyes",
|
||||
selected_values=selected,
|
||||
combine_mode=combine_mode,
|
||||
)
|
||||
return config, json.loads(config).get("summary", "")
|
||||
|
||||
|
||||
class SxCPCharacterClothing:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combine_mode": (["replace_axis", "add_to_axis"], {"default": "replace_axis"}),
|
||||
"softcore_source": (character_softcore_outfit_source_choices(), {"default": "no_change"}),
|
||||
"hardcore_state": (character_hardcore_clothing_state_choices(), {"default": "no_change"}),
|
||||
"custom_softcore_outfits": ("STRING", {"default": "", "multiline": True}),
|
||||
"custom_hardcore_clothing": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTERISTICS, "STRING")
|
||||
RETURN_NAMES = ("characteristics", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
combine_mode,
|
||||
softcore_source,
|
||||
hardcore_state,
|
||||
custom_softcore_outfits,
|
||||
custom_hardcore_clothing,
|
||||
characteristics="",
|
||||
):
|
||||
config = characteristics or ""
|
||||
if softcore_source != "no_change":
|
||||
config = build_characteristics_config_json(
|
||||
characteristics=config,
|
||||
axis="softcore_outfits",
|
||||
selected_values=character_softcore_outfit_values(softcore_source, custom_softcore_outfits),
|
||||
combine_mode=combine_mode,
|
||||
)
|
||||
if hardcore_state != "no_change":
|
||||
config = build_characteristics_config_json(
|
||||
characteristics=config,
|
||||
axis="hardcore_clothing",
|
||||
selected_values=character_hardcore_clothing_values(hardcore_state, custom_hardcore_clothing),
|
||||
combine_mode=combine_mode,
|
||||
)
|
||||
if not config:
|
||||
config = build_characteristics_config_json(axis="", selected_values=[])
|
||||
return config, json.loads(config).get("summary", "")
|
||||
|
||||
|
||||
class SxCPCharacterManualDetails:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combine_mode": (["merge_nonempty", "replace_all"], {"default": "merge_nonempty"}),
|
||||
"manual_age": ("STRING", {"default": ""}),
|
||||
"manual_body": ("STRING", {"default": ""}),
|
||||
"body_phrase": ("STRING", {"default": ""}),
|
||||
"skin": ("STRING", {"default": ""}),
|
||||
"hair": ("STRING", {"default": ""}),
|
||||
"eyes": ("STRING", {"default": ""}),
|
||||
"softcore_outfit": ("STRING", {"default": ""}),
|
||||
"hardcore_clothing": ("STRING", {"default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
"manual": (SXCP_CHARACTER_MANUAL,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTER_MANUAL, "STRING")
|
||||
RETURN_NAMES = ("manual", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
combine_mode,
|
||||
manual_age,
|
||||
manual_body,
|
||||
body_phrase,
|
||||
skin,
|
||||
hair,
|
||||
eyes,
|
||||
softcore_outfit,
|
||||
hardcore_clothing,
|
||||
manual="",
|
||||
):
|
||||
config = build_character_manual_config_json(
|
||||
manual=manual or "",
|
||||
combine_mode=combine_mode,
|
||||
manual_age=manual_age,
|
||||
manual_body=manual_body,
|
||||
body_phrase=body_phrase,
|
||||
skin=skin,
|
||||
hair=hair,
|
||||
eyes=eyes,
|
||||
softcore_outfit=softcore_outfit,
|
||||
hardcore_clothing=hardcore_clothing,
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
return config, parsed.get("summary", "")
|
||||
|
||||
|
||||
class SxCPCharacterSlot:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"subject_type": (["woman", "man"], {"default": "woman"}),
|
||||
"label": (character_label_choices(), {"default": "auto_chain"}),
|
||||
"slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}),
|
||||
"age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}),
|
||||
"ethnicity": (character_ethnicity_choices(), {"default": "random"}),
|
||||
"figure": (character_figure_choices(), {"default": "random"}),
|
||||
"body": ([choice for choice in character_body_choices() if choice != "manual"], {"default": "random"}),
|
||||
"descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}),
|
||||
"expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"presence_mode": (character_presence_choices(), {"default": "visible"}),
|
||||
"softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"manual": (SXCP_CHARACTER_MANUAL,),
|
||||
"ethnicity_list": (SXCP_ETHNICITY_LIST,),
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
"hair_config": (SXCP_HAIR_CONFIG,),
|
||||
"character_cast": (SXCP_CHARACTER_CAST,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTER_CAST, SXCP_CHARACTER_SLOT, "STRING", "STRING")
|
||||
RETURN_NAMES = ("character_cast", "character_slot", "summary", "status")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
subject_type,
|
||||
label,
|
||||
slot_seed,
|
||||
age,
|
||||
ethnicity,
|
||||
figure,
|
||||
body,
|
||||
descriptor_detail="auto",
|
||||
expression_enabled=True,
|
||||
expression_intensity=-1.0,
|
||||
presence_mode="visible",
|
||||
softcore_expression_intensity=-1.0,
|
||||
hardcore_expression_intensity=-1.0,
|
||||
character_cast="",
|
||||
ethnicity_list="",
|
||||
characteristics="",
|
||||
hair_config="",
|
||||
manual="",
|
||||
):
|
||||
result = build_character_slot_json(
|
||||
subject_type=subject_type,
|
||||
label=label,
|
||||
slot_seed=slot_seed,
|
||||
age=age,
|
||||
manual=manual,
|
||||
ethnicity=ethnicity_list or ethnicity,
|
||||
figure=figure,
|
||||
body=body,
|
||||
manual_body="",
|
||||
body_phrase="",
|
||||
skin="",
|
||||
hair="",
|
||||
characteristics=characteristics,
|
||||
hair_config=hair_config,
|
||||
eyes="",
|
||||
descriptor_detail=descriptor_detail,
|
||||
expression_enabled=expression_enabled,
|
||||
expression_intensity=expression_intensity,
|
||||
presence_mode=presence_mode,
|
||||
softcore_expression_intensity=softcore_expression_intensity,
|
||||
hardcore_expression_intensity=hardcore_expression_intensity,
|
||||
softcore_outfit="",
|
||||
hardcore_clothing="",
|
||||
enabled=enabled,
|
||||
character_cast=character_cast or "",
|
||||
)
|
||||
return result["character_cast"], result["character_slot"], result["summary"], result["status"]
|
||||
|
||||
|
||||
class SxCPWomanSlot:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"label": (character_label_choices(), {"default": "auto_chain"}),
|
||||
"slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}),
|
||||
"age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}),
|
||||
"ethnicity": (character_ethnicity_choices(), {"default": "random"}),
|
||||
"figure_bias": (character_figure_choices(), {"default": "random"}),
|
||||
"body": ([choice for choice in character_woman_body_choices() if choice != "manual"], {"default": "random"}),
|
||||
"descriptor_detail": (character_descriptor_detail_choices(), {"default": "auto"}),
|
||||
"expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"manual": (SXCP_CHARACTER_MANUAL,),
|
||||
"ethnicity_list": (SXCP_ETHNICITY_LIST,),
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
"hair_config": (SXCP_HAIR_CONFIG,),
|
||||
"character_cast": (SXCP_CHARACTER_CAST,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTER_CAST, SXCP_CHARACTER_SLOT, "STRING", "STRING")
|
||||
RETURN_NAMES = ("character_cast", "character_slot", "summary", "status")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
label,
|
||||
slot_seed,
|
||||
age,
|
||||
ethnicity,
|
||||
figure_bias,
|
||||
body,
|
||||
descriptor_detail="auto",
|
||||
expression_enabled=True,
|
||||
expression_intensity=-1.0,
|
||||
softcore_expression_intensity=-1.0,
|
||||
hardcore_expression_intensity=-1.0,
|
||||
character_cast="",
|
||||
ethnicity_list="",
|
||||
characteristics="",
|
||||
hair_config="",
|
||||
manual="",
|
||||
):
|
||||
result = build_character_slot_json(
|
||||
subject_type="woman",
|
||||
label=label,
|
||||
slot_seed=slot_seed,
|
||||
age=age,
|
||||
manual=manual,
|
||||
ethnicity=ethnicity_list or ethnicity,
|
||||
figure=figure_bias,
|
||||
body=body,
|
||||
manual_body="",
|
||||
body_phrase="",
|
||||
skin="",
|
||||
hair="",
|
||||
characteristics=characteristics,
|
||||
hair_config=hair_config,
|
||||
eyes="",
|
||||
descriptor_detail=descriptor_detail,
|
||||
expression_enabled=expression_enabled,
|
||||
expression_intensity=expression_intensity,
|
||||
softcore_expression_intensity=softcore_expression_intensity,
|
||||
hardcore_expression_intensity=hardcore_expression_intensity,
|
||||
softcore_outfit="",
|
||||
hardcore_clothing="",
|
||||
enabled=enabled,
|
||||
character_cast=character_cast or "",
|
||||
)
|
||||
return result["character_cast"], result["character_slot"], result["summary"], result["status"]
|
||||
|
||||
|
||||
class SxCPManSlot:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"label": (character_label_choices(), {"default": "auto_chain"}),
|
||||
"slot_seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF}),
|
||||
"age": ([choice for choice in character_age_choices() if choice != "manual"], {"default": "random"}),
|
||||
"ethnicity": (character_ethnicity_choices(), {"default": "random"}),
|
||||
"body": ([choice for choice in character_man_body_choices() if choice != "manual"], {"default": "random"}),
|
||||
"descriptor_detail": (character_descriptor_detail_choices(), {"default": "compact"}),
|
||||
"expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"presence_mode": (character_presence_choices(), {"default": "visible"}),
|
||||
"softcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"hardcore_expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"manual": (SXCP_CHARACTER_MANUAL,),
|
||||
"ethnicity_list": (SXCP_ETHNICITY_LIST,),
|
||||
"characteristics": (SXCP_CHARACTERISTICS,),
|
||||
"hair_config": (SXCP_HAIR_CONFIG,),
|
||||
"character_cast": (SXCP_CHARACTER_CAST,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTER_CAST, SXCP_CHARACTER_SLOT, "STRING", "STRING")
|
||||
RETURN_NAMES = ("character_cast", "character_slot", "summary", "status")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
label,
|
||||
slot_seed,
|
||||
age,
|
||||
ethnicity,
|
||||
body,
|
||||
descriptor_detail="compact",
|
||||
expression_enabled=True,
|
||||
expression_intensity=-1.0,
|
||||
presence_mode="visible",
|
||||
softcore_expression_intensity=-1.0,
|
||||
hardcore_expression_intensity=-1.0,
|
||||
character_cast="",
|
||||
ethnicity_list="",
|
||||
characteristics="",
|
||||
hair_config="",
|
||||
manual="",
|
||||
):
|
||||
result = build_character_slot_json(
|
||||
subject_type="man",
|
||||
label=label,
|
||||
slot_seed=slot_seed,
|
||||
age=age,
|
||||
manual=manual,
|
||||
ethnicity=ethnicity_list or ethnicity,
|
||||
figure="random",
|
||||
body=body,
|
||||
manual_body="",
|
||||
body_phrase="",
|
||||
skin="",
|
||||
hair="",
|
||||
characteristics=characteristics,
|
||||
hair_config=hair_config,
|
||||
eyes="",
|
||||
descriptor_detail=descriptor_detail,
|
||||
expression_enabled=expression_enabled,
|
||||
expression_intensity=expression_intensity,
|
||||
presence_mode=presence_mode,
|
||||
softcore_expression_intensity=softcore_expression_intensity,
|
||||
hardcore_expression_intensity=hardcore_expression_intensity,
|
||||
softcore_outfit="",
|
||||
hardcore_clothing="",
|
||||
enabled=enabled,
|
||||
character_cast=character_cast or "",
|
||||
)
|
||||
return result["character_cast"], result["character_slot"], result["summary"], result["status"]
|
||||
|
||||
|
||||
class SxCPCharacterProfileSave:
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"profile_name": ("STRING", {"default": "saved_character"}),
|
||||
"source": (["metadata_json", "character_slot", "manual"], {"default": "metadata_json"}),
|
||||
"subject_type": (["woman", "man"], {"default": "woman"}),
|
||||
"age": ("STRING", {"default": ""}),
|
||||
"body": ("STRING", {"default": ""}),
|
||||
"body_phrase": ("STRING", {"default": ""}),
|
||||
"skin": ("STRING", {"default": ""}),
|
||||
"hair": ("STRING", {"default": ""}),
|
||||
"eyes": ("STRING", {"default": ""}),
|
||||
"figure": ("STRING", {"default": ""}),
|
||||
"save_now": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"metadata_json": ("STRING", {"default": "", "multiline": True}),
|
||||
"character_slot": (SXCP_CHARACTER_SLOT,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTER_PROFILE, "STRING", "STRING", "STRING", "STRING", SXCP_CHARACTER_PROFILE)
|
||||
RETURN_NAMES = ("character_profile", "descriptor", "profile_name", "saved_path", "status", "profile_json")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
profile_name,
|
||||
source,
|
||||
subject_type,
|
||||
age,
|
||||
body,
|
||||
body_phrase,
|
||||
skin,
|
||||
hair,
|
||||
eyes,
|
||||
figure,
|
||||
save_now,
|
||||
metadata_json="",
|
||||
character_slot="",
|
||||
):
|
||||
profile = build_character_profile_json(
|
||||
profile_name=profile_name,
|
||||
source=source,
|
||||
metadata_json=metadata_json or "",
|
||||
character_slot=character_slot or "",
|
||||
subject_type=subject_type,
|
||||
age=age,
|
||||
body=body,
|
||||
body_phrase=body_phrase,
|
||||
skin=skin,
|
||||
hair=hair,
|
||||
eyes=eyes,
|
||||
figure=figure,
|
||||
save_now=save_now,
|
||||
)
|
||||
result = (
|
||||
profile["profile_json"],
|
||||
profile["descriptor"],
|
||||
profile["profile_name"],
|
||||
profile["saved_path"],
|
||||
profile["status"],
|
||||
profile["profile_json"],
|
||||
)
|
||||
return {
|
||||
"ui": {
|
||||
"profile_json": [profile["profile_json"]],
|
||||
"descriptor": [profile["descriptor"]],
|
||||
"profile_name": [profile["profile_name"]],
|
||||
"saved_path": [profile["saved_path"]],
|
||||
"status": [profile["status"]],
|
||||
},
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
class SxCPCharacterProfileLoad:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"profile_name": (character_profile_choices(), {"default": "manual"}),
|
||||
"rename_to": ("STRING", {"default": ""}),
|
||||
"delete_now": ("BOOLEAN", {"default": False}),
|
||||
"rename_now": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"manual_profile_name": ("STRING", {"default": ""}),
|
||||
"fallback_profile_json": (SXCP_CHARACTER_PROFILE,),
|
||||
"override_subject_type": (["keep_profile", "woman", "man"], {"default": "keep_profile"}),
|
||||
"override_age": ("STRING", {"default": ""}),
|
||||
"override_body": ("STRING", {"default": ""}),
|
||||
"override_body_phrase": ("STRING", {"default": ""}),
|
||||
"override_skin": ("STRING", {"default": ""}),
|
||||
"override_hair": ("STRING", {"default": ""}),
|
||||
"override_eyes": ("STRING", {"default": ""}),
|
||||
"override_figure": ("STRING", {"default": ""}),
|
||||
"override_descriptor_detail": (["keep_profile"] + character_descriptor_detail_choices(), {"default": "keep_profile"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CHARACTER_PROFILE, "STRING", "STRING", "STRING", "STRING", SXCP_CHARACTER_PROFILE)
|
||||
RETURN_NAMES = ("character_profile", "descriptor", "profile_name", "saved_path", "status", "profile_json")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
profile_name,
|
||||
rename_to,
|
||||
delete_now,
|
||||
rename_now,
|
||||
manual_profile_name="",
|
||||
fallback_profile_json="",
|
||||
override_subject_type="keep_profile",
|
||||
override_age="",
|
||||
override_body="",
|
||||
override_body_phrase="",
|
||||
override_skin="",
|
||||
override_hair="",
|
||||
override_eyes="",
|
||||
override_figure="",
|
||||
override_descriptor_detail="keep_profile",
|
||||
):
|
||||
chosen_name = manual_profile_name.strip() if profile_name == "manual" and manual_profile_name.strip() else profile_name
|
||||
profile = load_character_profile_json(
|
||||
profile_name=chosen_name,
|
||||
fallback_profile_json=fallback_profile_json or "",
|
||||
enabled=enabled,
|
||||
delete_now=delete_now,
|
||||
rename_now=rename_now,
|
||||
rename_to=rename_to,
|
||||
override_subject_type=override_subject_type,
|
||||
override_age=override_age,
|
||||
override_body=override_body,
|
||||
override_body_phrase=override_body_phrase,
|
||||
override_skin=override_skin,
|
||||
override_hair=override_hair,
|
||||
override_eyes=override_eyes,
|
||||
override_figure=override_figure,
|
||||
override_descriptor_detail=override_descriptor_detail,
|
||||
)
|
||||
return (
|
||||
profile["profile_json"],
|
||||
profile["descriptor"],
|
||||
profile["profile_name"],
|
||||
profile["saved_path"],
|
||||
profile["status"],
|
||||
profile["profile_json"],
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPHairLength": SxCPHairLength,
|
||||
"SxCPHairColor": SxCPHairColor,
|
||||
"SxCPHairStyle": SxCPHairStyle,
|
||||
"SxCPCharacterAgeRange": SxCPCharacterAgeRange,
|
||||
"SxCPCharacterBodyPool": SxCPCharacterBodyPool,
|
||||
"SxCPWomanBodyPool": SxCPWomanBodyPool,
|
||||
"SxCPManBodyPool": SxCPManBodyPool,
|
||||
"SxCPEyeColorPool": SxCPEyeColorPool,
|
||||
"SxCPCharacterClothing": SxCPCharacterClothing,
|
||||
"SxCPCharacterManualDetails": SxCPCharacterManualDetails,
|
||||
"SxCPWomanSlot": SxCPWomanSlot,
|
||||
"SxCPManSlot": SxCPManSlot,
|
||||
"SxCPCharacterSlot": SxCPCharacterSlot,
|
||||
"SxCPCharacterProfileSave": SxCPCharacterProfileSave,
|
||||
"SxCPCharacterProfileLoad": SxCPCharacterProfileLoad,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPHairLength": "SxCP Hair Length",
|
||||
"SxCPHairColor": "SxCP Hair Color",
|
||||
"SxCPHairStyle": "SxCP Hair Style/Cut",
|
||||
"SxCPCharacterAgeRange": "SxCP Character Age Range",
|
||||
"SxCPCharacterBodyPool": "SxCP Character Body Pool",
|
||||
"SxCPWomanBodyPool": "SxCP Woman Body Pool",
|
||||
"SxCPManBodyPool": "SxCP Man Body Pool",
|
||||
"SxCPEyeColorPool": "SxCP Eye Color Pool",
|
||||
"SxCPCharacterClothing": "SxCP Character Clothing",
|
||||
"SxCPCharacterManualDetails": "SxCP Character Manual Details",
|
||||
"SxCPWomanSlot": "SxCP Woman Slot",
|
||||
"SxCPManSlot": "SxCP Man Slot",
|
||||
"SxCPCharacterSlot": "SxCP Character Slot",
|
||||
"SxCPCharacterProfileSave": "SxCP Character Profile Save",
|
||||
"SxCPCharacterProfileLoad": "SxCP Character Profile Load",
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from .caption_naturalizer import naturalize_caption_with_trace
|
||||
from .caption_policy import caption_profile_choices, style_policy_choices
|
||||
from .formatter_detail import detail_level_choices
|
||||
from .formatter_input import INPUT_HINT_CAPTION_OR_PROMPT, INPUT_HINT_PROMPT, input_hint_choices
|
||||
from .formatter_target import target_choices
|
||||
from .krea_format_route import style_mode_choices
|
||||
from .krea_formatter import format_krea2_prompt
|
||||
from .sdxl_formatter import (
|
||||
format_sdxl_prompt,
|
||||
sdxl_formatter_profile_choices,
|
||||
sdxl_quality_preset_choices,
|
||||
sdxl_style_preset_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from caption_naturalizer import naturalize_caption_with_trace
|
||||
from caption_policy import caption_profile_choices, style_policy_choices
|
||||
from formatter_detail import detail_level_choices
|
||||
from formatter_input import INPUT_HINT_CAPTION_OR_PROMPT, INPUT_HINT_PROMPT, input_hint_choices
|
||||
from formatter_target import target_choices
|
||||
from krea_format_route import style_mode_choices
|
||||
from krea_formatter import format_krea2_prompt
|
||||
from sdxl_formatter import (
|
||||
format_sdxl_prompt,
|
||||
sdxl_formatter_profile_choices,
|
||||
sdxl_quality_preset_choices,
|
||||
sdxl_style_preset_choices,
|
||||
)
|
||||
|
||||
|
||||
class SxCPCaptionNaturalizer:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"source_text": ("STRING", {"default": "", "multiline": True}),
|
||||
"input_hint": (input_hint_choices(text_hint=INPUT_HINT_CAPTION_OR_PROMPT), {"default": "auto"}),
|
||||
"caption_profile": (caption_profile_choices(), {"default": "manual_controls"}),
|
||||
"detail_level": (detail_level_choices(), {"default": "balanced"}),
|
||||
"style_policy": (style_policy_choices(), {"default": "drop_style_tail"}),
|
||||
"trigger": ("STRING", {"default": "sxcppnl7"}),
|
||||
"include_trigger": ("BOOLEAN", {"default": True}),
|
||||
"target": (target_choices(), {"default": "auto"}),
|
||||
},
|
||||
"optional": {
|
||||
"source_text_input": ("STRING", {"forceInput": True}),
|
||||
"metadata_json": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("natural_caption", "method", "route_trace_json")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
source_text,
|
||||
input_hint,
|
||||
caption_profile,
|
||||
detail_level,
|
||||
style_policy,
|
||||
trigger,
|
||||
include_trigger,
|
||||
target="auto",
|
||||
source_text_input="",
|
||||
metadata_json="",
|
||||
):
|
||||
active_source_text = source_text_input or source_text or ""
|
||||
return naturalize_caption_with_trace(
|
||||
source_text=active_source_text,
|
||||
metadata_json=metadata_json or "",
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
trigger=trigger,
|
||||
include_trigger=include_trigger,
|
||||
detail_level=detail_level,
|
||||
style_policy=style_policy,
|
||||
caption_profile=caption_profile,
|
||||
)
|
||||
|
||||
|
||||
class SxCPKrea2Formatter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"source_text": ("STRING", {"default": "", "multiline": True}),
|
||||
"input_hint": (input_hint_choices(text_hint=INPUT_HINT_PROMPT), {"default": "auto"}),
|
||||
"target": (target_choices(), {"default": "auto"}),
|
||||
"detail_level": (detail_level_choices(), {"default": "balanced"}),
|
||||
"style_mode": (style_mode_choices(), {"default": "preserve"}),
|
||||
"preserve_trigger": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"source_text_input": ("STRING", {"forceInput": True}),
|
||||
"metadata_json": ("STRING", {"forceInput": True}),
|
||||
"negative_prompt": ("STRING", {"forceInput": True}),
|
||||
"extra_positive": ("STRING", {"default": "", "multiline": True}),
|
||||
"extra_negative": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"krea_prompt",
|
||||
"negative_prompt",
|
||||
"krea_softcore_prompt",
|
||||
"krea_hardcore_prompt",
|
||||
"softcore_negative_prompt",
|
||||
"hardcore_negative_prompt",
|
||||
"method",
|
||||
"route_trace_json",
|
||||
)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
source_text,
|
||||
input_hint,
|
||||
target,
|
||||
detail_level,
|
||||
style_mode,
|
||||
preserve_trigger,
|
||||
source_text_input="",
|
||||
metadata_json="",
|
||||
negative_prompt="",
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
):
|
||||
active_source_text = source_text_input or source_text or ""
|
||||
row = format_krea2_prompt(
|
||||
source_text=active_source_text,
|
||||
metadata_json=metadata_json or "",
|
||||
negative_prompt=negative_prompt or "",
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
detail_level=detail_level,
|
||||
style_mode=style_mode,
|
||||
preserve_trigger=preserve_trigger,
|
||||
extra_positive=extra_positive or "",
|
||||
extra_negative=extra_negative or "",
|
||||
)
|
||||
return (
|
||||
row["krea_prompt"],
|
||||
row["negative_prompt"],
|
||||
row["krea_softcore_prompt"],
|
||||
row["krea_hardcore_prompt"],
|
||||
row["softcore_negative_prompt"],
|
||||
row["hardcore_negative_prompt"],
|
||||
row["method"],
|
||||
row["route_trace_json"],
|
||||
)
|
||||
|
||||
|
||||
class SxCPSDXLFormatter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"source_text": ("STRING", {"default": "", "multiline": True}),
|
||||
"input_hint": (input_hint_choices(text_hint=INPUT_HINT_PROMPT), {"default": "auto"}),
|
||||
"target": (target_choices(), {"default": "auto"}),
|
||||
"formatter_profile": (sdxl_formatter_profile_choices(), {"default": "manual_controls"}),
|
||||
"style_preset": (sdxl_style_preset_choices(), {"default": "flat_vector_pony"}),
|
||||
"quality_preset": (sdxl_quality_preset_choices(), {"default": "pony_high"}),
|
||||
"trigger": ("STRING", {"default": "mythp0rt", "multiline": False}),
|
||||
"prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}),
|
||||
"preserve_trigger": ("BOOLEAN", {"default": False}),
|
||||
"nude_weight": ("FLOAT", {"default": 1.29, "min": 0.1, "max": 3.0, "step": 0.01}),
|
||||
},
|
||||
"optional": {
|
||||
"source_text_input": ("STRING", {"forceInput": True}),
|
||||
"metadata_json": ("STRING", {"forceInput": True}),
|
||||
"negative_prompt": ("STRING", {"forceInput": True}),
|
||||
"custom_style": ("STRING", {"default": "", "multiline": True}),
|
||||
"custom_quality": ("STRING", {"default": "", "multiline": True}),
|
||||
"extra_positive": ("STRING", {"default": "", "multiline": True}),
|
||||
"extra_negative": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"sdxl_prompt",
|
||||
"negative_prompt",
|
||||
"sdxl_softcore_prompt",
|
||||
"sdxl_hardcore_prompt",
|
||||
"softcore_negative_prompt",
|
||||
"hardcore_negative_prompt",
|
||||
"method",
|
||||
"route_trace_json",
|
||||
)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
source_text,
|
||||
input_hint,
|
||||
target,
|
||||
formatter_profile,
|
||||
style_preset,
|
||||
quality_preset,
|
||||
trigger,
|
||||
prepend_trigger_to_prompt,
|
||||
preserve_trigger,
|
||||
nude_weight,
|
||||
source_text_input="",
|
||||
metadata_json="",
|
||||
negative_prompt="",
|
||||
custom_style="",
|
||||
custom_quality="",
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
):
|
||||
active_source_text = source_text_input or source_text or ""
|
||||
row = format_sdxl_prompt(
|
||||
source_text=active_source_text,
|
||||
metadata_json=metadata_json or "",
|
||||
negative_prompt=negative_prompt or "",
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
formatter_profile=formatter_profile,
|
||||
style_preset=style_preset,
|
||||
quality_preset=quality_preset,
|
||||
trigger=trigger,
|
||||
prepend_trigger=prepend_trigger_to_prompt,
|
||||
preserve_trigger=preserve_trigger,
|
||||
nude_weight=nude_weight,
|
||||
custom_style=custom_style or "",
|
||||
custom_quality=custom_quality or "",
|
||||
extra_positive=extra_positive or "",
|
||||
extra_negative=extra_negative or "",
|
||||
)
|
||||
return (
|
||||
row["sdxl_prompt"],
|
||||
row["negative_prompt"],
|
||||
row["sdxl_softcore_prompt"],
|
||||
row["sdxl_hardcore_prompt"],
|
||||
row["softcore_negative_prompt"],
|
||||
row["hardcore_negative_prompt"],
|
||||
row["method"],
|
||||
row["route_trace_json"],
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPCaptionNaturalizer": SxCPCaptionNaturalizer,
|
||||
"SxCPKrea2Formatter": SxCPKrea2Formatter,
|
||||
"SxCPSDXLFormatter": SxCPSDXLFormatter,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPCaptionNaturalizer": "SxCP Caption Naturalizer",
|
||||
"SxCPKrea2Formatter": "SxCP Krea2 Formatter",
|
||||
"SxCPSDXLFormatter": "SxCP SDXL Formatter",
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from .hardcore_position_config import (
|
||||
build_hardcore_action_filter_json,
|
||||
build_hardcore_position_pool_json,
|
||||
hardcore_position_family_choices,
|
||||
hardcore_position_focus_choices,
|
||||
hardcore_position_key_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from hardcore_position_config import (
|
||||
build_hardcore_action_filter_json,
|
||||
build_hardcore_position_pool_json,
|
||||
hardcore_position_family_choices,
|
||||
hardcore_position_focus_choices,
|
||||
hardcore_position_key_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG"
|
||||
|
||||
|
||||
def _choice_input_key(prefix, choice):
|
||||
key = "".join(char if char.isalnum() else "_" for char in str(choice).lower()).strip("_")
|
||||
while "__" in key:
|
||||
key = key.replace("__", "_")
|
||||
return f"{prefix}_{key}"
|
||||
|
||||
|
||||
class SxCPHardcorePositionPool:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
required = {
|
||||
"combine_mode": (["replace", "add"], {"default": "replace"}),
|
||||
"family": (hardcore_position_family_choices(), {"default": "any"}),
|
||||
}
|
||||
for choice in hardcore_position_key_choices():
|
||||
required[_choice_input_key("include", choice)] = ("BOOLEAN", {"default": False})
|
||||
return {
|
||||
"required": required,
|
||||
"optional": {
|
||||
"hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("hardcore_position_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, combine_mode="replace", family="any", hardcore_position_config="", **kwargs):
|
||||
selected = [
|
||||
choice
|
||||
for choice in hardcore_position_key_choices()
|
||||
if bool(kwargs.get(_choice_input_key("include", choice), False))
|
||||
]
|
||||
config = build_hardcore_position_pool_json(
|
||||
hardcore_position_config=hardcore_position_config or "",
|
||||
combine_mode=combine_mode,
|
||||
family=family,
|
||||
selected_positions=selected,
|
||||
)
|
||||
return config, json.loads(config).get("summary", "")
|
||||
|
||||
|
||||
class SxCPHardcoreActionFilter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"focus": (hardcore_position_focus_choices(), {"default": "keep_pool"}),
|
||||
"allow_toys": ("BOOLEAN", {"default": False}),
|
||||
"allow_double": ("BOOLEAN", {"default": False}),
|
||||
"allow_penetration": ("BOOLEAN", {"default": True}),
|
||||
"allow_foreplay": ("BOOLEAN", {"default": True}),
|
||||
"allow_interaction": ("BOOLEAN", {"default": True}),
|
||||
"allow_manual": ("BOOLEAN", {"default": True}),
|
||||
"allow_oral": ("BOOLEAN", {"default": True}),
|
||||
"allow_outercourse": ("BOOLEAN", {"default": True}),
|
||||
"allow_anal": ("BOOLEAN", {"default": True}),
|
||||
"allow_climax": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_HARDCORE_POSITION_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("hardcore_position_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
focus,
|
||||
allow_toys,
|
||||
allow_double,
|
||||
allow_penetration,
|
||||
allow_foreplay,
|
||||
allow_interaction,
|
||||
allow_manual,
|
||||
allow_oral,
|
||||
allow_outercourse,
|
||||
allow_anal,
|
||||
allow_climax,
|
||||
hardcore_position_config="",
|
||||
):
|
||||
config = build_hardcore_action_filter_json(
|
||||
hardcore_position_config=hardcore_position_config or "",
|
||||
focus=focus,
|
||||
allow_toys=allow_toys,
|
||||
allow_double=allow_double,
|
||||
allow_penetration=allow_penetration,
|
||||
allow_foreplay=allow_foreplay,
|
||||
allow_interaction=allow_interaction,
|
||||
allow_manual=allow_manual,
|
||||
allow_oral=allow_oral,
|
||||
allow_outercourse=allow_outercourse,
|
||||
allow_anal=allow_anal,
|
||||
allow_climax=allow_climax,
|
||||
)
|
||||
return config, json.loads(config).get("summary", "")
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPHardcorePositionPool": SxCPHardcorePositionPool,
|
||||
"SxCPHardcoreActionFilter": SxCPHardcoreActionFilter,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPHardcorePositionPool": "SxCP Hardcore Position Pool",
|
||||
"SxCPHardcoreActionFilter": "SxCP Hardcore Action Filter",
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from .prompt_builder import (
|
||||
build_insta_of_options_json,
|
||||
build_insta_of_pair,
|
||||
camera_detail_choices,
|
||||
camera_mode_choices,
|
||||
ethnicity_choices,
|
||||
hardcore_detail_density_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from prompt_builder import (
|
||||
build_insta_of_options_json,
|
||||
build_insta_of_pair,
|
||||
camera_detail_choices,
|
||||
camera_mode_choices,
|
||||
ethnicity_choices,
|
||||
hardcore_detail_density_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG"
|
||||
SXCP_CAMERA_CONFIG = "SXCP_CAMERA_CONFIG"
|
||||
SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG"
|
||||
SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG"
|
||||
SXCP_INSTA_OF_OPTIONS = "SXCP_INSTA_OF_OPTIONS"
|
||||
SXCP_HARDCORE_POSITION_CONFIG = "SXCP_HARDCORE_POSITION_CONFIG"
|
||||
SXCP_CHARACTER_CAST = "SXCP_CHARACTER_CAST"
|
||||
SXCP_CHARACTER_PROFILE = "SXCP_CHARACTER_PROFILE"
|
||||
SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST"
|
||||
SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG"
|
||||
|
||||
|
||||
class SxCPInstaOFOptions:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"softcore_cast": (["solo", "same_as_hardcore"], {"default": "solo"}),
|
||||
"hardcore_cast": (["use_counts", "couple", "threesome", "group"], {"default": "use_counts"}),
|
||||
"hardcore_women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
|
||||
"hardcore_men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
|
||||
"softcore_level": (["social_tease", "lingerie_tease", "implied_nude", "explicit_tease", "explicit_nude"], {"default": "lingerie_tease"}),
|
||||
"hardcore_level": (["explicit", "hardcore"], {"default": "hardcore"}),
|
||||
"softcore_expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"hardcore_expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"softcore_expression_intensity": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"hardcore_expression_intensity": ("FLOAT", {"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"platform_style": (["hybrid", "instagram", "onlyfans"], {"default": "hybrid"}),
|
||||
"continuity": (["same_creator_same_room", "same_creator_new_scene"], {"default": "same_creator_same_room"}),
|
||||
"hardcore_clothing_continuity": (["none", "same_outfit", "partially_removed", "implied_nude", "explicit_nude"], {"default": "partially_removed"}),
|
||||
"softcore_camera_mode": (["from_camera_config"] + camera_mode_choices(), {"default": "handheld_selfie"}),
|
||||
"hardcore_camera_mode": (["from_camera_config", "same_as_softcore"] + camera_mode_choices(), {"default": "from_camera_config"}),
|
||||
"camera_detail": (["from_camera_config"] + camera_detail_choices(), {"default": "from_camera_config"}),
|
||||
"hardcore_detail_density": (hardcore_detail_density_choices(), {"default": "balanced"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_INSTA_OF_OPTIONS,)
|
||||
RETURN_NAMES = ("options_json",)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
softcore_cast,
|
||||
hardcore_cast,
|
||||
hardcore_women_count,
|
||||
hardcore_men_count,
|
||||
softcore_level,
|
||||
hardcore_level,
|
||||
softcore_expression_enabled,
|
||||
hardcore_expression_enabled,
|
||||
softcore_expression_intensity,
|
||||
hardcore_expression_intensity,
|
||||
platform_style,
|
||||
continuity,
|
||||
hardcore_clothing_continuity,
|
||||
softcore_camera_mode,
|
||||
hardcore_camera_mode,
|
||||
camera_detail,
|
||||
hardcore_detail_density,
|
||||
):
|
||||
return (
|
||||
build_insta_of_options_json(
|
||||
softcore_cast=softcore_cast,
|
||||
hardcore_cast=hardcore_cast,
|
||||
hardcore_women_count=hardcore_women_count,
|
||||
hardcore_men_count=hardcore_men_count,
|
||||
softcore_level=softcore_level,
|
||||
hardcore_level=hardcore_level,
|
||||
softcore_expression_enabled=softcore_expression_enabled,
|
||||
hardcore_expression_enabled=hardcore_expression_enabled,
|
||||
softcore_expression_intensity=softcore_expression_intensity,
|
||||
hardcore_expression_intensity=hardcore_expression_intensity,
|
||||
platform_style=platform_style,
|
||||
continuity=continuity,
|
||||
hardcore_clothing_continuity=hardcore_clothing_continuity,
|
||||
softcore_camera_mode=softcore_camera_mode,
|
||||
hardcore_camera_mode=hardcore_camera_mode,
|
||||
camera_detail=camera_detail,
|
||||
hardcore_detail_density=hardcore_detail_density,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SxCPInstaOFPromptPair:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"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}),
|
||||
"ethnicity": (ethnicity_choices(), {"default": "any"}),
|
||||
"figure": (["random", "curvy", "balanced", "bombshell"], {"default": "random"}),
|
||||
"trigger": ("STRING", {"default": "sxcpinup_coloredpencil"}),
|
||||
"prepend_trigger_to_prompt": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"seed_config": (SXCP_SEED_CONFIG,),
|
||||
"options_json": (SXCP_INSTA_OF_OPTIONS,),
|
||||
"filter_config": (SXCP_FILTER_CONFIG,),
|
||||
"ethnicity_list": (SXCP_ETHNICITY_LIST,),
|
||||
"camera_config": (SXCP_CAMERA_CONFIG,),
|
||||
"softcore_camera_config": (SXCP_CAMERA_CONFIG,),
|
||||
"hardcore_camera_config": (SXCP_CAMERA_CONFIG,),
|
||||
"location_config": (SXCP_LOCATION_CONFIG,),
|
||||
"composition_config": (SXCP_COMPOSITION_CONFIG,),
|
||||
"character_profile": (SXCP_CHARACTER_PROFILE,),
|
||||
"character_cast": (SXCP_CHARACTER_CAST,),
|
||||
"hardcore_position_config": (SXCP_HARDCORE_POSITION_CONFIG,),
|
||||
"extra_positive": ("STRING", {"default": "", "multiline": True}),
|
||||
"extra_negative": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"softcore_prompt",
|
||||
"hardcore_prompt",
|
||||
"softcore_negative_prompt",
|
||||
"hardcore_negative_prompt",
|
||||
"softcore_caption",
|
||||
"hardcore_caption",
|
||||
"shared_descriptor",
|
||||
"metadata_json",
|
||||
)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
row_number,
|
||||
start_index,
|
||||
seed,
|
||||
ethnicity,
|
||||
figure,
|
||||
trigger,
|
||||
prepend_trigger_to_prompt,
|
||||
seed_config="",
|
||||
options_json="",
|
||||
filter_config="",
|
||||
ethnicity_list="",
|
||||
camera_config="",
|
||||
softcore_camera_config="",
|
||||
hardcore_camera_config="",
|
||||
location_config="",
|
||||
composition_config="",
|
||||
character_profile="",
|
||||
character_cast="",
|
||||
hardcore_position_config="",
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
no_plus_women=False,
|
||||
no_black=False,
|
||||
):
|
||||
row = build_insta_of_pair(
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
trigger=trigger,
|
||||
prepend_trigger_to_prompt=prepend_trigger_to_prompt,
|
||||
seed_config=seed_config or "",
|
||||
options_json=options_json or "",
|
||||
filter_config=ethnicity_list or filter_config or "",
|
||||
camera_config=camera_config or "",
|
||||
softcore_camera_config=softcore_camera_config or "",
|
||||
hardcore_camera_config=hardcore_camera_config or "",
|
||||
location_config=location_config or "",
|
||||
composition_config=composition_config or "",
|
||||
character_profile=character_profile or "",
|
||||
character_cast=character_cast or "",
|
||||
hardcore_position_config=hardcore_position_config or "",
|
||||
extra_positive=extra_positive or "",
|
||||
extra_negative=extra_negative or "",
|
||||
)
|
||||
return (
|
||||
row["softcore_prompt"],
|
||||
row["hardcore_prompt"],
|
||||
row["softcore_negative_prompt"],
|
||||
row["hardcore_negative_prompt"],
|
||||
row["softcore_caption"],
|
||||
row["hardcore_caption"],
|
||||
row["shared_descriptor"],
|
||||
json.dumps(row, ensure_ascii=True, sort_keys=True),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPInstaOFOptions": SxCPInstaOFOptions,
|
||||
"SxCPInstaOFPromptPair": SxCPInstaOFPromptPair,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPInstaOFOptions": "SxCP Insta/OF Options",
|
||||
"SxCPInstaOFPromptPair": "SxCP Insta/OF Prompt Pair",
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
from .filter_config import (
|
||||
build_ethnicity_list_json,
|
||||
build_filter_config_json,
|
||||
)
|
||||
from .generation_profile_config import (
|
||||
build_generation_profile_json,
|
||||
generation_profile_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from filter_config import (
|
||||
build_ethnicity_list_json,
|
||||
build_filter_config_json,
|
||||
)
|
||||
from generation_profile_config import (
|
||||
build_generation_profile_json,
|
||||
generation_profile_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_GENERATION_PROFILE = "SXCP_GENERATION_PROFILE"
|
||||
SXCP_FILTER_CONFIG = "SXCP_FILTER_CONFIG"
|
||||
SXCP_ETHNICITY_LIST = "SXCP_ETHNICITY_LIST"
|
||||
|
||||
|
||||
class SxCPGenerationProfile:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"profile": (generation_profile_choices(), {"default": "balanced"}),
|
||||
"clothing_override": (["profile_default", "random", "full", "minimal"], {"default": "profile_default"}),
|
||||
"poses_override": (["profile_default", "random", "standard", "evocative"], {"default": "profile_default"}),
|
||||
"expression_enabled": ("BOOLEAN", {"default": True}),
|
||||
"expression_intensity_mode": (["profile_default", "random", "fixed"], {"default": "profile_default"}),
|
||||
"expression_intensity": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"backside_bias": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"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_policy": (["profile_default", "prepend_trigger", "do_not_prepend"], {"default": "profile_default"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_GENERATION_PROFILE, "STRING")
|
||||
RETURN_NAMES = ("generation_profile", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
profile,
|
||||
clothing_override,
|
||||
poses_override,
|
||||
expression_enabled,
|
||||
expression_intensity_mode,
|
||||
expression_intensity,
|
||||
backside_bias,
|
||||
minimal_clothing_ratio,
|
||||
standard_pose_ratio,
|
||||
trigger_policy,
|
||||
):
|
||||
config = build_generation_profile_json(
|
||||
profile=profile,
|
||||
clothing_override=clothing_override,
|
||||
poses_override=poses_override,
|
||||
expression_enabled=expression_enabled,
|
||||
expression_intensity_mode=expression_intensity_mode,
|
||||
expression_intensity=expression_intensity,
|
||||
backside_bias=backside_bias,
|
||||
minimal_clothing_ratio=minimal_clothing_ratio,
|
||||
standard_pose_ratio=standard_pose_ratio,
|
||||
trigger_policy=trigger_policy,
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
if not parsed.get("expression_enabled", True):
|
||||
expression_summary = "expression disabled"
|
||||
elif float(parsed.get("expression_intensity", 0.5)) < 0:
|
||||
expression_summary = "expression random"
|
||||
else:
|
||||
expression_summary = f"expression {parsed['expression_intensity']}"
|
||||
summary = f"{parsed['profile']}: {parsed['clothing']}, {parsed['poses']}, {expression_summary}"
|
||||
return config, summary
|
||||
|
||||
|
||||
class SxCPAdvancedFilters:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"include_european": ("BOOLEAN", {"default": True}),
|
||||
"include_mediterranean_mena": ("BOOLEAN", {"default": True}),
|
||||
"include_latina": ("BOOLEAN", {"default": True}),
|
||||
"include_east_asian": ("BOOLEAN", {"default": True}),
|
||||
"include_southeast_asian": ("BOOLEAN", {"default": True}),
|
||||
"include_south_asian": ("BOOLEAN", {"default": True}),
|
||||
"include_black_african": ("BOOLEAN", {"default": True}),
|
||||
"include_indigenous": ("BOOLEAN", {"default": True}),
|
||||
"include_mixed": ("BOOLEAN", {"default": True}),
|
||||
"include_plus_size": ("BOOLEAN", {"default": True}),
|
||||
"figure": (["random", "curvy", "balanced", "bombshell"], {"default": "random"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_FILTER_CONFIG,)
|
||||
RETURN_NAMES = ("filter_config",)
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
include_european,
|
||||
include_mediterranean_mena,
|
||||
include_latina,
|
||||
include_east_asian,
|
||||
include_southeast_asian,
|
||||
include_south_asian,
|
||||
include_black_african,
|
||||
include_indigenous,
|
||||
include_mixed,
|
||||
include_plus_size,
|
||||
figure,
|
||||
):
|
||||
return (
|
||||
build_filter_config_json(
|
||||
figure=figure,
|
||||
include_european=include_european,
|
||||
include_mediterranean_mena=include_mediterranean_mena,
|
||||
include_latina=include_latina,
|
||||
include_east_asian=include_east_asian,
|
||||
include_southeast_asian=include_southeast_asian,
|
||||
include_south_asian=include_south_asian,
|
||||
include_black_african=include_black_african,
|
||||
include_indigenous=include_indigenous,
|
||||
include_mixed=include_mixed,
|
||||
include_plus_size=include_plus_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SxCPEthnicityList:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"include_european": ("BOOLEAN", {"default": False}),
|
||||
"include_mediterranean_mena": ("BOOLEAN", {"default": False}),
|
||||
"include_latina": ("BOOLEAN", {"default": False}),
|
||||
"include_east_asian": ("BOOLEAN", {"default": False}),
|
||||
"include_southeast_asian": ("BOOLEAN", {"default": False}),
|
||||
"include_south_asian": ("BOOLEAN", {"default": False}),
|
||||
"include_black_african": ("BOOLEAN", {"default": False}),
|
||||
"include_indigenous": ("BOOLEAN", {"default": False}),
|
||||
"include_mixed": ("BOOLEAN", {"default": False}),
|
||||
"include_asian": ("BOOLEAN", {"default": False}),
|
||||
"include_white_asian": ("BOOLEAN", {"default": False}),
|
||||
"include_western_european": ("BOOLEAN", {"default": False}),
|
||||
"include_french_european": ("BOOLEAN", {"default": False}),
|
||||
"include_germanic_european": ("BOOLEAN", {"default": False}),
|
||||
"include_nordic_european": ("BOOLEAN", {"default": False}),
|
||||
"include_celtic_european": ("BOOLEAN", {"default": False}),
|
||||
"include_slavic_european": ("BOOLEAN", {"default": False}),
|
||||
"include_baltic_european": ("BOOLEAN", {"default": False}),
|
||||
"include_alpine_european": ("BOOLEAN", {"default": False}),
|
||||
"include_balkan_european": ("BOOLEAN", {"default": False}),
|
||||
"include_greek_mediterranean": ("BOOLEAN", {"default": False}),
|
||||
"include_italian_mediterranean": ("BOOLEAN", {"default": False}),
|
||||
"include_iberian_mediterranean": ("BOOLEAN", {"default": False}),
|
||||
"strict_excludes": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_ETHNICITY_LIST, SXCP_FILTER_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("ethnicity_list", "filter_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
include_european,
|
||||
include_mediterranean_mena,
|
||||
include_latina,
|
||||
include_east_asian,
|
||||
include_southeast_asian,
|
||||
include_south_asian,
|
||||
include_black_african,
|
||||
include_indigenous,
|
||||
include_mixed,
|
||||
include_asian,
|
||||
include_white_asian,
|
||||
include_western_european,
|
||||
include_french_european,
|
||||
include_germanic_european,
|
||||
include_nordic_european,
|
||||
include_celtic_european,
|
||||
include_slavic_european,
|
||||
include_baltic_european,
|
||||
include_alpine_european,
|
||||
include_balkan_european,
|
||||
include_greek_mediterranean,
|
||||
include_italian_mediterranean,
|
||||
include_iberian_mediterranean,
|
||||
strict_excludes,
|
||||
):
|
||||
result = build_ethnicity_list_json(
|
||||
include_european=include_european,
|
||||
include_mediterranean_mena=include_mediterranean_mena,
|
||||
include_latina=include_latina,
|
||||
include_east_asian=include_east_asian,
|
||||
include_southeast_asian=include_southeast_asian,
|
||||
include_south_asian=include_south_asian,
|
||||
include_black_african=include_black_african,
|
||||
include_indigenous=include_indigenous,
|
||||
include_mixed=include_mixed,
|
||||
include_asian=include_asian,
|
||||
include_white_asian=include_white_asian,
|
||||
include_western_european=include_western_european,
|
||||
include_french_european=include_french_european,
|
||||
include_germanic_european=include_germanic_european,
|
||||
include_nordic_european=include_nordic_european,
|
||||
include_celtic_european=include_celtic_european,
|
||||
include_slavic_european=include_slavic_european,
|
||||
include_baltic_european=include_baltic_european,
|
||||
include_alpine_european=include_alpine_european,
|
||||
include_balkan_european=include_balkan_european,
|
||||
include_greek_mediterranean=include_greek_mediterranean,
|
||||
include_italian_mediterranean=include_italian_mediterranean,
|
||||
include_iberian_mediterranean=include_iberian_mediterranean,
|
||||
strict_excludes=strict_excludes,
|
||||
)
|
||||
return result["ethnicity"], result["filter_config"], result["summary"]
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPGenerationProfile": SxCPGenerationProfile,
|
||||
"SxCPAdvancedFilters": SxCPAdvancedFilters,
|
||||
"SxCPEthnicityList": SxCPEthnicityList,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPGenerationProfile": "SxCP Generation Profile",
|
||||
"SxCPAdvancedFilters": "SxCP Advanced Filters",
|
||||
"SxCPEthnicityList": "SxCP Ethnicity List",
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
|
||||
try:
|
||||
from .category_cast_config import (
|
||||
build_cast_config_json,
|
||||
build_category_config_json,
|
||||
cast_preset_choices,
|
||||
category_preset_choices,
|
||||
)
|
||||
from .prompt_builder import (
|
||||
subcategory_choices,
|
||||
)
|
||||
from .seed_config import configured_seed_from_axes
|
||||
from .location_config import (
|
||||
build_composition_pool_json,
|
||||
build_location_pool_json,
|
||||
build_thematic_location_json,
|
||||
composition_pool_preset_choices,
|
||||
location_pool_preset_choices,
|
||||
location_theme_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from category_cast_config import (
|
||||
build_cast_config_json,
|
||||
build_category_config_json,
|
||||
cast_preset_choices,
|
||||
category_preset_choices,
|
||||
)
|
||||
from prompt_builder import (
|
||||
subcategory_choices,
|
||||
)
|
||||
from seed_config import configured_seed_from_axes
|
||||
from location_config import (
|
||||
build_composition_pool_json,
|
||||
build_location_pool_json,
|
||||
build_thematic_location_json,
|
||||
composition_pool_preset_choices,
|
||||
location_pool_preset_choices,
|
||||
location_theme_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_CATEGORY_CONFIG = "SXCP_CATEGORY_CONFIG"
|
||||
SXCP_LOCATION_CONFIG = "SXCP_LOCATION_CONFIG"
|
||||
SXCP_COMPOSITION_CONFIG = "SXCP_COMPOSITION_CONFIG"
|
||||
SXCP_CAST_CONFIG = "SXCP_CAST_CONFIG"
|
||||
SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG"
|
||||
|
||||
|
||||
class SxCPCategoryPreset:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"preset": (category_preset_choices(), {"default": "auto_weighted"}),
|
||||
"subcategory": (subcategory_choices(), {"default": "random"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CATEGORY_CONFIG, "STRING", "STRING")
|
||||
RETURN_NAMES = ("category_config", "category", "subcategory")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, preset, subcategory):
|
||||
config = build_category_config_json(preset=preset, subcategory=subcategory)
|
||||
parsed = json.loads(config)
|
||||
return config, parsed["category"], parsed["subcategory"]
|
||||
|
||||
|
||||
class SxCPLocationPool:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"combine_mode": (["replace", "add"], {"default": "replace"}),
|
||||
"preset": (location_pool_preset_choices(), {"default": "custom_only"}),
|
||||
"custom_locations": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"location_config": (SXCP_LOCATION_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_LOCATION_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("location_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, enabled, combine_mode, preset, custom_locations, location_config=""):
|
||||
config = build_location_pool_json(
|
||||
enabled=enabled,
|
||||
combine_mode=combine_mode,
|
||||
preset=preset,
|
||||
custom_locations=custom_locations or "",
|
||||
location_config=location_config or "",
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
return config, parsed.get("summary", "")
|
||||
|
||||
|
||||
class SxCPCompositionPool:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"combine_mode": (["replace", "add"], {"default": "replace"}),
|
||||
"preset": (composition_pool_preset_choices(), {"default": "no_outfit_check"}),
|
||||
"custom_compositions": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"composition_config": (SXCP_COMPOSITION_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_COMPOSITION_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("composition_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, enabled, combine_mode, preset, custom_compositions, composition_config=""):
|
||||
config = build_composition_pool_json(
|
||||
enabled=enabled,
|
||||
combine_mode=combine_mode,
|
||||
preset=preset,
|
||||
custom_compositions=custom_compositions or "",
|
||||
composition_config=composition_config or "",
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
return config, parsed.get("summary", "")
|
||||
|
||||
|
||||
class SxCPLocationTheme:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"combine_mode": (["replace", "add"], {"default": "replace"}),
|
||||
"theme": (location_theme_choices(), {"default": "semi_public_affair"}),
|
||||
"custom_locations": ("STRING", {"default": "", "multiline": True}),
|
||||
"custom_compositions": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"location_config": (SXCP_LOCATION_CONFIG,),
|
||||
"composition_config": (SXCP_COMPOSITION_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_LOCATION_CONFIG, SXCP_COMPOSITION_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("location_config", "composition_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
combine_mode,
|
||||
theme,
|
||||
custom_locations,
|
||||
custom_compositions,
|
||||
location_config="",
|
||||
composition_config="",
|
||||
):
|
||||
return build_thematic_location_json(
|
||||
enabled=enabled,
|
||||
combine_mode=combine_mode,
|
||||
theme=theme,
|
||||
custom_locations=custom_locations or "",
|
||||
custom_compositions=custom_compositions or "",
|
||||
location_config=location_config or "",
|
||||
composition_config=composition_config or "",
|
||||
)
|
||||
|
||||
|
||||
class SxCPCastControl:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"cast_mode": (cast_preset_choices(), {"default": "mixed_couple"}),
|
||||
"women_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
|
||||
"men_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CAST_CONFIG, "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, cast_mode, women_count, men_count):
|
||||
config = build_cast_config_json(cast_mode=cast_mode, women_count=women_count, men_count=men_count)
|
||||
parsed = json.loads(config)
|
||||
summary = f"{parsed['women_count']} women, {parsed['men_count']} men"
|
||||
return config, parsed["women_count"], parsed["men_count"], summary
|
||||
|
||||
|
||||
class SxCPCastBias:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}),
|
||||
"row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}),
|
||||
"women_weights": ("STRING", {"default": "0.60,0.25,0.10,0.05"}),
|
||||
"women_start_count": ("INT", {"default": 1, "min": 0, "max": 12, "step": 1}),
|
||||
"men_weights": ("STRING", {"default": "0.45,0.40,0.10,0.05"}),
|
||||
"men_start_count": ("INT", {"default": 0, "min": 0, "max": 12, "step": 1}),
|
||||
"empty_behavior": (["force_one_woman", "force_one_man", "allow_empty"], {"default": "force_one_woman"}),
|
||||
},
|
||||
"optional": {
|
||||
"seed_config": (SXCP_SEED_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_CAST_CONFIG, "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("cast_config", "women_count", "men_count", "cast_summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
@staticmethod
|
||||
def _configured_cast_seed(seed_config):
|
||||
return configured_seed_from_axes(
|
||||
seed_config,
|
||||
("category", "content", "role"),
|
||||
extra_keys=("seed", "global_seed"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _weight_pairs(weights_text, start_count):
|
||||
pairs = []
|
||||
start = max(0, min(12, int(start_count)))
|
||||
parts = str(weights_text or "").replace("\n", ",").split(",")
|
||||
for offset, raw in enumerate(parts):
|
||||
count = start + offset
|
||||
if count > 12:
|
||||
break
|
||||
try:
|
||||
weight = float(raw.strip())
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if weight > 0:
|
||||
pairs.append((count, weight))
|
||||
return pairs or [(start, 1.0)]
|
||||
|
||||
@staticmethod
|
||||
def _weighted_count(rng, pairs):
|
||||
total = sum(weight for _count, weight in pairs)
|
||||
point = rng.random() * total
|
||||
upto = 0.0
|
||||
for count, weight in pairs:
|
||||
upto += weight
|
||||
if point <= upto:
|
||||
return int(count)
|
||||
return int(pairs[-1][0])
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, *args, **kwargs):
|
||||
seed_value = kwargs.get("seed")
|
||||
if seed_value is None and args:
|
||||
seed_value = args[0]
|
||||
seed_config = kwargs.get("seed_config", "")
|
||||
if not seed_config and len(args) > 7:
|
||||
seed_config = args[7]
|
||||
try:
|
||||
seed = int(seed_value)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
if seed < 0 and cls._configured_cast_seed(seed_config) is None:
|
||||
return random.random()
|
||||
return tuple(args), tuple(sorted(kwargs.items()))
|
||||
|
||||
def build(
|
||||
self,
|
||||
seed,
|
||||
row_number,
|
||||
women_weights,
|
||||
women_start_count,
|
||||
men_weights,
|
||||
men_start_count,
|
||||
empty_behavior,
|
||||
seed_config="",
|
||||
):
|
||||
configured_seed = self._configured_cast_seed(seed_config)
|
||||
if configured_seed is None and int(seed) < 0:
|
||||
rng = random.Random(random.getrandbits(64))
|
||||
else:
|
||||
cast_seed = configured_seed if configured_seed is not None else int(seed)
|
||||
rng = random.Random(f"sxcp_cast_bias:{cast_seed}:{int(row_number)}")
|
||||
women_pairs = self._weight_pairs(women_weights, women_start_count)
|
||||
men_pairs = self._weight_pairs(men_weights, men_start_count)
|
||||
women_count = self._weighted_count(rng, women_pairs)
|
||||
men_count = self._weighted_count(rng, men_pairs)
|
||||
if women_count + men_count == 0:
|
||||
if empty_behavior == "force_one_man":
|
||||
men_count = 1
|
||||
elif empty_behavior != "allow_empty":
|
||||
women_count = 1
|
||||
config = build_cast_config_json(cast_mode="custom_counts", women_count=women_count, men_count=men_count)
|
||||
parsed = json.loads(config)
|
||||
summary = f"weighted cast: {parsed['women_count']} women, {parsed['men_count']} men"
|
||||
return config, parsed["women_count"], parsed["men_count"], summary
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPCategoryPreset": SxCPCategoryPreset,
|
||||
"SxCPLocationPool": SxCPLocationPool,
|
||||
"SxCPCompositionPool": SxCPCompositionPool,
|
||||
"SxCPLocationTheme": SxCPLocationTheme,
|
||||
"SxCPCastControl": SxCPCastControl,
|
||||
"SxCPCastBias": SxCPCastBias,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPCategoryPreset": "SxCP Category Preset",
|
||||
"SxCPLocationPool": "SxCP Location Pool",
|
||||
"SxCPCompositionPool": "SxCP Composition Pool",
|
||||
"SxCPLocationTheme": "SxCP Location Theme",
|
||||
"SxCPCastControl": "SxCP Cast Control",
|
||||
"SxCPCastBias": "SxCP Cast Bias",
|
||||
}
|
||||
+1262
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,519 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
|
||||
try:
|
||||
from .seed_config import (
|
||||
build_seed_config_json,
|
||||
build_seed_lock_config_json,
|
||||
configured_seed_from_axes,
|
||||
normalize_reroll_axis,
|
||||
seed_reroll_axis_choices,
|
||||
seed_mode_choices,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
from seed_config import (
|
||||
build_seed_config_json,
|
||||
build_seed_lock_config_json,
|
||||
configured_seed_from_axes,
|
||||
normalize_reroll_axis,
|
||||
seed_reroll_axis_choices,
|
||||
seed_mode_choices,
|
||||
)
|
||||
|
||||
|
||||
SXCP_SEED_CONFIG = "SXCP_SEED_CONFIG"
|
||||
|
||||
SDXL_BUCKET_RESOLUTIONS = [
|
||||
{"orientation": "portrait", "width": 896, "height": 1792, "aspect": 0.50, "mp": 1.61},
|
||||
{"orientation": "portrait", "width": 960, "height": 1664, "aspect": 0.58, "mp": 1.60},
|
||||
{"orientation": "portrait", "width": 1024, "height": 1600, "aspect": 0.64, "mp": 1.64},
|
||||
{"orientation": "portrait", "width": 1088, "height": 1472, "aspect": 0.74, "mp": 1.60},
|
||||
{"orientation": "portrait", "width": 1152, "height": 1408, "aspect": 0.82, "mp": 1.62},
|
||||
{"orientation": "portrait", "width": 1216, "height": 1344, "aspect": 0.90, "mp": 1.63},
|
||||
{"orientation": "square", "width": 1280, "height": 1280, "aspect": 1.00, "mp": 1.64},
|
||||
{"orientation": "landscape", "width": 1344, "height": 1216, "aspect": 1.11, "mp": 1.63},
|
||||
{"orientation": "landscape", "width": 1408, "height": 1152, "aspect": 1.22, "mp": 1.62},
|
||||
{"orientation": "landscape", "width": 1472, "height": 1088, "aspect": 1.35, "mp": 1.60},
|
||||
{"orientation": "landscape", "width": 1536, "height": 1024, "aspect": 1.50, "mp": 1.57},
|
||||
]
|
||||
|
||||
KREA2_API_ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "2.35:1", "4:5", "2:3", "9:16"]
|
||||
KREA2_ASPECT_RATIOS = KREA2_API_ASPECT_RATIOS + ["8:9", "21:9", "9:21", "3:1", "1:3"]
|
||||
KREA2_MEGAPIXEL_PRESETS = [
|
||||
"1.0MP",
|
||||
"1.25MP",
|
||||
"1.5MP",
|
||||
"1.75MP",
|
||||
"2.0MP",
|
||||
"2.25MP",
|
||||
"2.5MP",
|
||||
"2.75MP",
|
||||
"3.0MP",
|
||||
"3.25MP",
|
||||
"3.5MP",
|
||||
"3.75MP",
|
||||
"4.0MP",
|
||||
"max_for_aspect",
|
||||
]
|
||||
|
||||
|
||||
class SxCPSeedControl:
|
||||
SEED_AXES = (
|
||||
"category",
|
||||
"subcategory",
|
||||
"content",
|
||||
"person",
|
||||
"scene",
|
||||
"pose",
|
||||
"role",
|
||||
"expression",
|
||||
"composition",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}
|
||||
required = {}
|
||||
for axis in cls.SEED_AXES:
|
||||
required[f"{axis}_seed_mode"] = (seed_mode_choices(), {"default": "auto"})
|
||||
required[f"{axis}_seed"] = ("INT", seed_spec)
|
||||
return {"required": required}
|
||||
|
||||
RETURN_TYPES = (SXCP_SEED_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("seed_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, *args, **kwargs):
|
||||
values = list(args) + list(kwargs.values())
|
||||
if "random" in values:
|
||||
return random.random()
|
||||
return tuple(args), tuple(sorted(kwargs.items()))
|
||||
|
||||
@classmethod
|
||||
def _summary(cls, config_json):
|
||||
try:
|
||||
config = json.loads(config_json)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return "invalid seed config"
|
||||
parts = []
|
||||
for axis in cls.SEED_AXES:
|
||||
try:
|
||||
value = int(config.get(f"{axis}_seed", -1))
|
||||
except (TypeError, ValueError):
|
||||
value = -1
|
||||
parts.append(f"{axis}={'follow_main' if value < 0 else value}")
|
||||
return "resolved seeds: " + "; ".join(parts)
|
||||
|
||||
def build(
|
||||
self,
|
||||
category_seed_mode,
|
||||
category_seed,
|
||||
subcategory_seed_mode,
|
||||
subcategory_seed,
|
||||
content_seed_mode,
|
||||
content_seed,
|
||||
person_seed_mode,
|
||||
person_seed,
|
||||
scene_seed_mode,
|
||||
scene_seed,
|
||||
pose_seed_mode,
|
||||
pose_seed,
|
||||
role_seed_mode,
|
||||
role_seed,
|
||||
expression_seed_mode,
|
||||
expression_seed,
|
||||
composition_seed_mode,
|
||||
composition_seed,
|
||||
):
|
||||
config = 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,
|
||||
category_seed_mode=category_seed_mode,
|
||||
subcategory_seed_mode=subcategory_seed_mode,
|
||||
content_seed_mode=content_seed_mode,
|
||||
person_seed_mode=person_seed_mode,
|
||||
scene_seed_mode=scene_seed_mode,
|
||||
pose_seed_mode=pose_seed_mode,
|
||||
role_seed_mode=role_seed_mode,
|
||||
expression_seed_mode=expression_seed_mode,
|
||||
composition_seed_mode=composition_seed_mode,
|
||||
)
|
||||
return (
|
||||
config,
|
||||
self._summary(config),
|
||||
)
|
||||
|
||||
|
||||
class SxCPGlobalSeed:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1}
|
||||
return {
|
||||
"required": {
|
||||
"global_seed": ("INT", seed_spec),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", SXCP_SEED_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("seed", "seed_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, global_seed):
|
||||
seed = max(0, min(0xFFFFFFFF, int(global_seed)))
|
||||
config = build_seed_lock_config_json(base_seed=seed, reroll_axis="none", reroll_seed=-1)
|
||||
return seed, config, f"global seed {seed}; all axes locked"
|
||||
|
||||
|
||||
class SxCPSeedLocker:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
seed_spec = {"default": 20260614, "min": 0, "max": 0xFFFFFFFF, "step": 1}
|
||||
reroll_seed_spec = {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}
|
||||
return {
|
||||
"required": {
|
||||
"base_seed": ("INT", seed_spec),
|
||||
"reroll_axis": (
|
||||
seed_reroll_axis_choices(),
|
||||
{"default": "none"},
|
||||
),
|
||||
"reroll_seed": ("INT", reroll_seed_spec),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_SEED_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("seed_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(self, base_seed, reroll_axis, reroll_seed):
|
||||
normalized_axis = normalize_reroll_axis(reroll_axis)
|
||||
config = build_seed_lock_config_json(base_seed=base_seed, reroll_axis=normalized_axis, reroll_seed=reroll_seed)
|
||||
summary = f"base {base_seed}; reroll {normalized_axis} with {'main seed' if int(reroll_seed) < 0 else reroll_seed}"
|
||||
return config, summary
|
||||
|
||||
|
||||
class SxCPSDXLBucketSize:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"orientation": (["any", "portrait", "square", "landscape"], {"default": "any"}),
|
||||
"seed": ("INT", {"default": -1, "min": -1, "max": 0xFFFFFFFF, "step": 1}),
|
||||
"row_number": ("INT", {"default": 1, "min": 1, "max": 1000000, "step": 1}),
|
||||
"bucket_index": ("INT", {"default": 0, "min": 0, "max": len(SDXL_BUCKET_RESOLUTIONS), "step": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"seed_config": (SXCP_SEED_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "STRING", "STRING", "FLOAT", "FLOAT", "INT", "STRING")
|
||||
RETURN_NAMES = ("width", "height", "resolution", "orientation", "aspect", "megapixels", "bucket_index", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder/util"
|
||||
|
||||
@staticmethod
|
||||
def _configured_bucket_seed(seed_config):
|
||||
return configured_seed_from_axes(
|
||||
seed_config,
|
||||
("composition", "content"),
|
||||
extra_keys=("seed", "global_seed"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, *args, **kwargs):
|
||||
seed_value = kwargs.get("seed")
|
||||
if seed_value is None and len(args) > 1:
|
||||
seed_value = args[1]
|
||||
bucket_index = kwargs.get("bucket_index")
|
||||
if bucket_index is None and len(args) > 3:
|
||||
bucket_index = args[3]
|
||||
seed_config = kwargs.get("seed_config", "")
|
||||
if not seed_config and len(args) > 4:
|
||||
seed_config = args[4]
|
||||
try:
|
||||
seed = int(seed_value)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
try:
|
||||
index = int(bucket_index)
|
||||
except (TypeError, ValueError):
|
||||
index = 0
|
||||
if index <= 0 and seed < 0 and cls._configured_bucket_seed(seed_config) is None:
|
||||
return random.random()
|
||||
return tuple(args), tuple(sorted(kwargs.items()))
|
||||
|
||||
def build(self, orientation, seed, row_number, bucket_index, seed_config=""):
|
||||
orientation = str(orientation or "any").strip().lower()
|
||||
pool = [
|
||||
(index + 1, bucket)
|
||||
for index, bucket in enumerate(SDXL_BUCKET_RESOLUTIONS)
|
||||
if orientation == "any" or bucket["orientation"] == orientation
|
||||
]
|
||||
if not pool:
|
||||
pool = list(enumerate(SDXL_BUCKET_RESOLUTIONS, start=1))
|
||||
if int(bucket_index) > 0:
|
||||
pool_position = max(1, min(len(pool), int(bucket_index))) - 1
|
||||
else:
|
||||
configured_seed = self._configured_bucket_seed(seed_config)
|
||||
if configured_seed is None and int(seed) < 0:
|
||||
rng = random.Random(random.getrandbits(64))
|
||||
else:
|
||||
bucket_seed = configured_seed if configured_seed is not None else int(seed)
|
||||
rng = random.Random(f"sdxl_bucket:{bucket_seed}:{int(row_number)}:{orientation}")
|
||||
pool_position = rng.randrange(len(pool))
|
||||
selected_index, selected = pool[pool_position]
|
||||
width = int(selected["width"])
|
||||
height = int(selected["height"])
|
||||
selected_orientation = str(selected["orientation"])
|
||||
aspect = float(selected["aspect"])
|
||||
mp = float(selected["mp"])
|
||||
resolution = f"{width}x{height}"
|
||||
summary = (
|
||||
f"{selected_orientation} bucket {pool_position + 1}/{len(pool)} "
|
||||
f"(table {selected_index}): {resolution}, aspect {aspect:.2f}, {mp:.2f} MP"
|
||||
)
|
||||
return width, height, resolution, selected_orientation, aspect, mp, selected_index, summary
|
||||
|
||||
|
||||
class SxCPKrea2ResolutionSelector:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"megapixels": (KREA2_MEGAPIXEL_PRESETS, {"default": "1.0MP"}),
|
||||
"aspect_ratio": (KREA2_ASPECT_RATIOS, {"default": "1:1"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "STRING", "STRING", "STRING", "STRING", "FLOAT", "FLOAT", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"width",
|
||||
"height",
|
||||
"resolution",
|
||||
"aspect_ratio",
|
||||
"api_aspect_ratio",
|
||||
"api_resolution",
|
||||
"megapixels",
|
||||
"max_megapixels_for_aspect",
|
||||
"orientation",
|
||||
"summary",
|
||||
"config_json",
|
||||
)
|
||||
FUNCTION = "select"
|
||||
CATEGORY = "prompt_builder/util"
|
||||
|
||||
@staticmethod
|
||||
def _aspect_value(aspect_ratio, custom_aspect_width, custom_aspect_height, rng):
|
||||
selected = str(aspect_ratio or "1:1").strip()
|
||||
if selected == "random_api":
|
||||
selected = rng.choice(KREA2_API_ASPECT_RATIOS)
|
||||
if selected == "custom":
|
||||
width = max(0.1, float(custom_aspect_width))
|
||||
height = max(0.1, float(custom_aspect_height))
|
||||
return selected, width / height
|
||||
try:
|
||||
left, right = selected.split(":", 1)
|
||||
return selected, max(0.01, float(left) / float(right))
|
||||
except (TypeError, ValueError):
|
||||
return "1:1", 1.0
|
||||
|
||||
@staticmethod
|
||||
def _closest_api_aspect(ratio):
|
||||
def parse(value):
|
||||
left, right = value.split(":", 1)
|
||||
return float(left) / float(right)
|
||||
|
||||
return min(KREA2_API_ASPECT_RATIOS, key=lambda item: abs(math.log(parse(item) / max(0.01, ratio))))
|
||||
|
||||
@staticmethod
|
||||
def _continuous_limit_mp(ratio, max_long_edge, max_megapixels):
|
||||
ratio = max(0.01, float(ratio))
|
||||
max_long = max(16.0, float(max_long_edge))
|
||||
if ratio >= 1.0:
|
||||
exact_width = max_long
|
||||
exact_height = max_long / ratio
|
||||
else:
|
||||
exact_width = max_long * ratio
|
||||
exact_height = max_long
|
||||
exact_mp = (exact_width * exact_height) / 1_000_000.0
|
||||
return max(0.01, min(float(max_megapixels), exact_mp))
|
||||
|
||||
@staticmethod
|
||||
def _nearby_multiples(value, multiple):
|
||||
scaled = float(value) / float(multiple)
|
||||
values = {
|
||||
int(math.floor(scaled)) * multiple,
|
||||
int(round(scaled)) * multiple,
|
||||
int(math.ceil(scaled)) * multiple,
|
||||
}
|
||||
return {int(v) for v in values if int(v) > 0}
|
||||
|
||||
@classmethod
|
||||
def _candidate_sizes(cls, ratio, max_long_edge, max_megapixels, multiple):
|
||||
max_long = max(multiple, int(max_long_edge) // multiple * multiple)
|
||||
max_pixels = float(max_megapixels) * 1_000_000.0
|
||||
candidates = set()
|
||||
for width in range(multiple, max_long + 1, multiple):
|
||||
for height in cls._nearby_multiples(float(width) / ratio, multiple):
|
||||
candidates.add((width, height))
|
||||
for height in range(multiple, max_long + 1, multiple):
|
||||
for width in cls._nearby_multiples(float(height) * ratio, multiple):
|
||||
candidates.add((width, height))
|
||||
valid = []
|
||||
for width, height in candidates:
|
||||
if width < multiple or height < multiple:
|
||||
continue
|
||||
if max(width, height) > max_long:
|
||||
continue
|
||||
if width * height > max_pixels + 1:
|
||||
continue
|
||||
valid.append((width, height))
|
||||
return valid
|
||||
|
||||
@classmethod
|
||||
def _best_size(cls, ratio, target_megapixels, max_long_edge, max_megapixels, multiple):
|
||||
candidates = cls._candidate_sizes(ratio, max_long_edge, max_megapixels, multiple)
|
||||
if not candidates:
|
||||
fallback = max(multiple, int(max_long_edge) // multiple * multiple)
|
||||
return fallback, fallback, (fallback * fallback) / 1_000_000.0, 1.0
|
||||
target = max((multiple * multiple) / 1_000_000.0, float(target_megapixels))
|
||||
best = None
|
||||
best_score = None
|
||||
for width, height in candidates:
|
||||
actual_mp = (width * height) / 1_000_000.0
|
||||
actual_ratio = float(width) / float(height)
|
||||
ratio_error = abs(math.log(actual_ratio / max(0.01, ratio)))
|
||||
mp_error = abs(actual_mp - target) / max(target, 0.01)
|
||||
score = ratio_error * 4.0 + mp_error
|
||||
if best_score is None or score < best_score:
|
||||
best = (width, height, actual_mp, actual_ratio)
|
||||
best_score = score
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def _profile_limits(profile, custom_max_long_edge, custom_max_megapixels):
|
||||
profile = str(profile or "turbo_local_2k").strip()
|
||||
if profile == "raw_local_1k":
|
||||
return 1024, 1.05, "Krea2 RAW local explicit size, up to 1K"
|
||||
if profile == "api_hosted_1k":
|
||||
return 1024, 1.05, "Krea hosted API fields, 1K only"
|
||||
if profile == "custom_limit":
|
||||
return max(256, int(custom_max_long_edge)), max(0.10, float(custom_max_megapixels)), "custom explicit size limit"
|
||||
return 2048, 4.20, "Krea2 Turbo local explicit size, up to 2K"
|
||||
|
||||
@staticmethod
|
||||
def _preset_megapixels(megapixel_preset):
|
||||
value = str(megapixel_preset or "1.0MP").strip()
|
||||
if value.endswith("MP"):
|
||||
try:
|
||||
return float(value[:-2])
|
||||
except ValueError:
|
||||
return 1.0
|
||||
return None
|
||||
|
||||
def select(self, megapixels, aspect_ratio):
|
||||
multiple = 16
|
||||
profile = "turbo_local_2k"
|
||||
max_long_edge, max_profile_mp, _profile_label = self._profile_limits(profile, 2048, 4.20)
|
||||
resolved_aspect, ratio = self._aspect_value(aspect_ratio, 1.0, 1.0, random.Random("krea2_resolution"))
|
||||
api_aspect_ratio = resolved_aspect if resolved_aspect in KREA2_API_ASPECT_RATIOS else self._closest_api_aspect(ratio)
|
||||
|
||||
continuous_max_mp = self._continuous_limit_mp(ratio, max_long_edge, max_profile_mp)
|
||||
max_width, max_height, max_actual_mp, max_actual_ratio = self._best_size(
|
||||
ratio, continuous_max_mp, max_long_edge, max_profile_mp, multiple
|
||||
)
|
||||
|
||||
preset = str(megapixels or "1.0MP").strip()
|
||||
target_mp = self._preset_megapixels(preset)
|
||||
if preset == "max_for_aspect":
|
||||
target_mp = max_actual_mp
|
||||
if target_mp is None:
|
||||
target_mp = 1.0
|
||||
|
||||
clamped = target_mp > max_actual_mp + 0.001
|
||||
effective_target_mp = min(float(target_mp), max_actual_mp)
|
||||
width, height, actual_mp, actual_ratio = self._best_size(
|
||||
ratio, effective_target_mp, max_long_edge, max_profile_mp, multiple
|
||||
)
|
||||
orientation = "square"
|
||||
if width > height:
|
||||
orientation = "landscape"
|
||||
elif height > width:
|
||||
orientation = "portrait"
|
||||
|
||||
resolution = f"{width}x{height}"
|
||||
api_resolution = "1K"
|
||||
summary_parts = [
|
||||
f"{resolution}",
|
||||
f"{actual_mp:.2f} MP",
|
||||
f"aspect {resolved_aspect} ({actual_ratio:.3f})",
|
||||
f"max for aspect {max_width}x{max_height} / {max_actual_mp:.2f} MP",
|
||||
"Krea2 Turbo 2K",
|
||||
f"API equivalent {api_aspect_ratio} {api_resolution}",
|
||||
]
|
||||
if clamped:
|
||||
summary_parts.append(f"target {target_mp:.2f} MP clamped to aspect/profile limit")
|
||||
summary = "; ".join(summary_parts)
|
||||
|
||||
config = {
|
||||
"profile": profile,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"resolution": resolution,
|
||||
"aspect_ratio": resolved_aspect,
|
||||
"aspect_ratio_value": actual_ratio,
|
||||
"target_megapixels": round(float(target_mp), 4),
|
||||
"megapixels": round(actual_mp, 4),
|
||||
"max_width_for_aspect": max_width,
|
||||
"max_height_for_aspect": max_height,
|
||||
"max_megapixels_for_aspect": round(max_actual_mp, 4),
|
||||
"api_aspect_ratio": api_aspect_ratio,
|
||||
"api_resolution": api_resolution,
|
||||
"orientation": orientation,
|
||||
"round_to": multiple,
|
||||
"clamped": clamped,
|
||||
}
|
||||
return (
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
resolved_aspect,
|
||||
api_aspect_ratio,
|
||||
api_resolution,
|
||||
round(actual_mp, 4),
|
||||
round(max_actual_mp, 4),
|
||||
orientation,
|
||||
summary,
|
||||
json.dumps(config, ensure_ascii=True, sort_keys=True),
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SxCPGlobalSeed": SxCPGlobalSeed,
|
||||
"SxCPSeedControl": SxCPSeedControl,
|
||||
"SxCPSeedLocker": SxCPSeedLocker,
|
||||
"SxCPSDXLBucketSize": SxCPSDXLBucketSize,
|
||||
"SxCPKrea2ResolutionSelector": SxCPKrea2ResolutionSelector,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPGlobalSeed": "SxCP Global Seed",
|
||||
"SxCPSeedControl": "SxCP Seed Control",
|
||||
"SxCPSeedLocker": "SxCP Seed Locker",
|
||||
"SxCPSDXLBucketSize": "SxCP SDXL Bucket Size",
|
||||
"SxCPKrea2ResolutionSelector": "SxCP Krea2 Resolution Selector",
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
COMMON_INPUT_TOOLTIPS = {
|
||||
"row_number": "Generation row to use. Changing it advances the deterministic selection without changing the main seed.",
|
||||
"start_index": "Metadata/output index offset only. It does not limit category pools or random choices.",
|
||||
"seed": "Main seed used when no more specific seed config overrides an axis.",
|
||||
"global_seed": "One seed that locks all prompt axes so the same inputs can recreate the same result.",
|
||||
"base_seed": "Base seed used by Seed Locker before applying a selected reroll axis.",
|
||||
"reroll_seed": "Seed for the selected reroll axis. Use -1 to derive it from the base seed.",
|
||||
"category": "Main category source. auto_weighted is legacy random; auto_full mixes legacy random with JSON categories including hardcore.",
|
||||
"subcategory": "Specific subcategory, or random to choose within the selected category.",
|
||||
"category_config": "Category/subcategory config from SxCP Category Preset.",
|
||||
"cast_config": "Cast size config from SxCP Cast Control.",
|
||||
"generation_profile": "General style/intensity profile from SxCP Generation Profile.",
|
||||
"filter_config": "Ethnicity/body filter config. Ethnicity List can feed this too.",
|
||||
"ethnicity_list": "Optional ethnicity pool. When connected, it overrides the slot or generator ethnicity picker.",
|
||||
"seed_config": "Per-axis seed config. Use Global Seed for full reproducibility, Seed Locker to reroll one axis, or Seed Control for manual axis seeds.",
|
||||
"camera_config": "Camera config consumed only by nodes/options set to from_camera_config.",
|
||||
"location_config": "Location config from SxCP Location Pool. It can replace or add to the category scene pool.",
|
||||
"composition_config": "Composition config from SxCP Composition Pool or Location Theme. It can replace or add framing options.",
|
||||
"softcore_camera_config": "Camera config used only for the softcore Insta/OF prompt. Falls back to camera_config if empty.",
|
||||
"hardcore_camera_config": "Camera config used only for the hardcore Insta/OF prompt. Falls back to camera_config if empty.",
|
||||
"character_profile": "Saved or loaded single-character profile. Character slots override this for configured casts.",
|
||||
"character_cast": "Chain slot output into the next slot, then into the generator. The first enabled woman/man in chain order becomes A, then B, and so on.",
|
||||
"character_slot": "Single slot payload for saving/loading profiles or debugging one character.",
|
||||
"hardcore_position_config": "Hardcore action/position config. Chain Position Pool into Action Filter, then into the generator.",
|
||||
"custom_locations": "One custom location per line. Use plain text, or slug: location text.",
|
||||
"custom_compositions": "One custom composition/framing phrase per line.",
|
||||
"theme": "Matched location and composition theme, useful when the place needs compatible framing.",
|
||||
"metadata_json": "Structured metadata from an SxCP generator. Prefer this over raw prompt text for formatters and profile save.",
|
||||
"scene": "Structured v2 scene context. Chain Scene nodes in order, then connect to Scene Output or Scene Pair Output.",
|
||||
"softcore_scene": "Softcore branch scene from Scene Branch Pair, optionally refined by Softcore Branch Options.",
|
||||
"hardcore_scene": "Hardcore branch scene from Scene Branch Pair, optionally refined by Hardcore Branch Options.",
|
||||
"target_formatter": "Intended downstream formatter target. The scene stores this as metadata; use formatter nodes for final rewriting.",
|
||||
"category_preset": "Category preset this scene should render through when no explicit category config overrides it.",
|
||||
"central_subject": "Who should be visually central in this scene metadata.",
|
||||
"pov_participant": "Optional participant treated as the first-person viewer in later character/camera logic.",
|
||||
"subject_label": "Character label affected by this layer. all applies the layer to every matching character slot.",
|
||||
"wardrobe_prompt": "Optional wardrobe/set note carried as scene metadata and compatibility extra prompt text.",
|
||||
"custom_location": "Exact location text for this scene. One line or JSON entry is enough.",
|
||||
"location_note": "Additional location wording merged into the location pool entry.",
|
||||
"foreground_anchors": "Objects or surfaces that should stay near the camera or lower frame.",
|
||||
"repeated_background": "Repeating background structure such as desks, doors, shelves, pillars, or windows.",
|
||||
"props": "Scene props or set dressing objects that make the location readable.",
|
||||
"set_prompt": "Freeform set-dressing sentence appended to the scene layer.",
|
||||
"blocking_mode": "Broad body-placement mode. custom lets custom_blocking carry the exact placement.",
|
||||
"subject_placement": "Where the subject or cast sits in the space: foreground, near desk edge, on bed, in aisle, etc.",
|
||||
"body_relation": "Spatial relationship between participants, separate from the action itself.",
|
||||
"custom_blocking": "Exact blocking/positioning sentence for the scene layer.",
|
||||
"scene_kind": "Regular, softcore, or hardcore intent for this action layer.",
|
||||
"action_prompt": "Action text stored separately from blocking and camera. Use position pools for hardcore randomization when possible.",
|
||||
"performance_prompt": "Expression, gaze, hand, and body-performance note stored separately from the action.",
|
||||
"camera_prompt": "Optional freeform camera note kept as scene metadata. Camera config still controls existing formatter behavior.",
|
||||
"custom_composition": "Exact composition/framing entry to add to the composition pool.",
|
||||
"composition_prompt": "Additional composition wording merged into the composition layer.",
|
||||
"lighting_source": "Main light source family for the scene.",
|
||||
"lighting_softness": "Softness of the light: soft, balanced, or hard.",
|
||||
"lighting_contrast": "Overall contrast level for the lighting layer.",
|
||||
"color_temperature": "Warm, neutral, cool, or mixed color temperature.",
|
||||
"custom_lighting": "Exact lighting sentence for the scene layer.",
|
||||
"continuity": "How branch outputs share cast/location setup between softcore and hardcore scenes.",
|
||||
"platform_style": "Instagram/OnlyFans styling bias for Scene Pair Output.",
|
||||
"softcore_cast": "Whether the softcore branch uses a solo creator or the same cast as the hardcore branch.",
|
||||
"hardcore_cast": "Hardcore branch cast preset or explicit count mode.",
|
||||
"softcore_level": "Softcore exposure/style level for Scene Pair Output.",
|
||||
"hardcore_level": "Hardcore intensity level for Scene Pair Output.",
|
||||
"softcore_camera_mode": "Softcore branch camera mode, or from_camera_config to use the connected scene camera.",
|
||||
"hardcore_camera_mode": "Hardcore branch camera mode, or from_camera_config to use the connected scene camera.",
|
||||
"hardcore_clothing_continuity": "How wardrobe is rendered in the hardcore branch. explicit_nude avoids clothing-token conflicts.",
|
||||
"hardcore_detail_density": "How much explicit action detail the current formatter route keeps for the hardcore branch.",
|
||||
"source_text": "Raw prompt, caption, or metadata JSON depending on input_hint.",
|
||||
"source_text_input": "Optional linked raw prompt/caption input. When connected, it overrides the source_text widget.",
|
||||
"input_hint": "Tells the node how to interpret source_text. auto tries metadata first.",
|
||||
"target": "For dual prompts, choose which side to output as the main Krea prompt.",
|
||||
"detail_level": "Controls how much detail the rewriter keeps. concise is shorter, dense keeps more clauses.",
|
||||
"style_mode": "How strongly the formatter rewrites visual style terms.",
|
||||
"preserve_trigger": "Keep the trigger token in the formatted prompt instead of stripping it.",
|
||||
"negative_prompt": "Negative prompt text to pass through or merge with generated negatives.",
|
||||
"extra_positive": "Extra positive text appended after the generated prompt.",
|
||||
"extra_negative": "Extra negative text appended after the generated negative prompt.",
|
||||
"trigger": "Training or style trigger token.",
|
||||
"prepend_trigger_to_prompt": "If enabled, put the trigger token at the start of generated prompts.",
|
||||
"bucket_index": "0 picks a random bucket. 1+ picks that position inside the selected orientation pool.",
|
||||
"megapixels": "Approximate megapixel count for the selected bucket.",
|
||||
"enabled": "Enable this node's effect while keeping it wired in the graph.",
|
||||
"combine_mode": "replace starts a new pool/config; add merges selected values into the incoming config.",
|
||||
"manual": "Manual character details config from Manual Details. Non-empty fields override generated slot details.",
|
||||
"characteristics": "Chainable character pool for controlled randomness such as age, body, eyes, and clothing.",
|
||||
"hair_config": "Chainable hair pool. Combine length, color, and style nodes before the character slot.",
|
||||
"summary": "Human-readable description of the config produced by this node.",
|
||||
"status": "Operation result or warning text.",
|
||||
"profile_name": "Name of the profile to save, load, rename, or delete.",
|
||||
"manual_profile_name": "Free-text profile name used when profile_name is set to manual.",
|
||||
"fallback_profile_json": "Profile JSON to use when a named profile cannot be loaded.",
|
||||
"rename_to": "New profile name used only when rename_now is enabled.",
|
||||
"save_now": "Writes the profile to disk only when enabled. Keep off while adjusting fields.",
|
||||
"delete_now": "Deletes the selected profile when enabled.",
|
||||
"rename_now": "Renames the selected profile when enabled.",
|
||||
"source": "Where the save node reads character data from.",
|
||||
"subject_type": "Character type for this slot or saved profile.",
|
||||
"label": "Character label. auto_chain assigns the next Woman/Man label based on incoming cast order.",
|
||||
"slot_seed": "Per-character seed for age/body/hair/eyes random picks. Use -1 to derive from the generator person seed.",
|
||||
"age": "Age choice for this slot. Use Age Range node for a custom random age pool.",
|
||||
"manual_age": "Exact age phrase override, for example '32-year-old adult'.",
|
||||
"ethnicity": "Ethnicity choice for this slot. A connected Ethnicity List overrides this picker.",
|
||||
"figure": "General figure bias for generated body descriptors.",
|
||||
"figure_bias": "Woman-slot figure bias. Body pool can give more precise body choices.",
|
||||
"women_count": "Number of women in the generated cast when no Insta/OF preset overrides it.",
|
||||
"men_count": "Number of men in the generated cast when no Insta/OF preset overrides it.",
|
||||
"hardcore_women_count": "Number of women in the hardcore cast when hardcore_cast is use_counts.",
|
||||
"hardcore_men_count": "Number of men in the hardcore cast when hardcore_cast is use_counts.",
|
||||
"body": "Body choice for this slot. A Body Pool node can replace the random list.",
|
||||
"manual_body": "Exact body phrase override.",
|
||||
"body_phrase": "Full custom body wording. Use only when the body picker is not specific enough.",
|
||||
"skin": "Manual skin/complexion phrase.",
|
||||
"hair": "Manual hair phrase. Hair config nodes are better for controlled random choices.",
|
||||
"eyes": "Manual eye description.",
|
||||
"descriptor_detail": "How detailed this character's descriptor should be. Men usually work better compact.",
|
||||
"expression_enabled": "Master expression toggle for this generator or character.",
|
||||
"expression_intensity": "Expression intensity from 0 to 1. On the direct builder, -1 randomizes per row; on slots, -1 inherits the generator setting.",
|
||||
"expression_intensity_mode": "For Generation Profile, choose profile_default, random, or fixed value from expression_intensity.",
|
||||
"softcore_expression_intensity": "Optional expression intensity override for this character in softcore prompts. -1 inherits.",
|
||||
"hardcore_expression_intensity": "Optional expression intensity override for this character in hardcore prompts. -1 inherits.",
|
||||
"presence_mode": "Controls whether the character is visible or acts as the male POV participant.",
|
||||
"softcore_outfit": "Manual softcore outfit text for this character. Prefer Character Clothing for reusable outfit pools.",
|
||||
"hardcore_clothing": "Manual hardcore exposure text for this character. Use explicit nude states when you do not want clothing words repeated.",
|
||||
"custom_softcore_outfits": "One custom softcore outfit per line. Used when softcore_source is custom.",
|
||||
"custom_hardcore_clothing": "One custom hardcore clothing/body exposure state per line.",
|
||||
"condition": "Loop condition. When false, the loop stops and passes current values through.",
|
||||
"total": "Total number of loop iterations.",
|
||||
"skip": "Number of leading loop indexes to skip. skip=1 starts generation at index 2.",
|
||||
"collection": "Existing accumulated value or batch.",
|
||||
"value": "Value to append, store, or pass through.",
|
||||
"store_key": "Accumulator memory key. Leave blank for node-local storage, or use the same text to share one store across nodes.",
|
||||
"store_key_input": "Connect SxCP Accumulator store_key output here so preview/delete/save targets the same store and keeps a graph dependency.",
|
||||
"action": "Accumulator operation: append, replace, clear, read, or append a variant.",
|
||||
"max_items": "Maximum stored entries kept in this accumulator.",
|
||||
"image_batch_mode": "How image entries are batched when dimensions differ.",
|
||||
"skip_empty": "Ignore empty inputs instead of adding blank entries.",
|
||||
"image": "Image to store in the accumulator.",
|
||||
"entry_id": "Stable ID used for replace_by_entry_id or grouping variants.",
|
||||
"entry_tag": "Optional suffix added to entry_id.",
|
||||
"preview_limit": "Maximum number of accumulator images to show in the preview panel.",
|
||||
"view_mode": "Accumulator Preview layout: grid shows many images, carousel shows one large image at a time.",
|
||||
"zoom_level": "Accumulator Preview image scale. Higher values make grid thumbnails or carousel image area larger.",
|
||||
"carousel_index": "1-based image position shown in carousel mode. The previous/next buttons update this value.",
|
||||
"delete_action": "Optional execution-time delete operation. JS buttons can delete interactively without setting this.",
|
||||
"delete_entry_id": "Entry id to delete when delete_action is delete_entry_id.",
|
||||
"delete_index": "1-based entry index to delete when delete_action is delete_index. 0 disables it.",
|
||||
"save_batch": "When enabled, save all current accumulator images during node execution once finished is true.",
|
||||
"finished": "Gate for saving. Outside a loop, leave true; inside a loop, wire a final-iteration signal.",
|
||||
"save_path": "Folder to save the accumulator batch. Relative paths are inside ComfyUI output; absolute paths are used directly.",
|
||||
"filename_prefix": "Filename prefix for saved accumulator images.",
|
||||
"clear_after_save": "Clear the accumulator store after a successful batch save.",
|
||||
"preview_text": "Serialized persistent text preview. It is updated after execution and saved with the workflow.",
|
||||
"preview_format": "How to convert an arbitrary input to preview text.",
|
||||
"max_chars": "Maximum stored preview characters. 0 disables truncation.",
|
||||
"mode": "Switch direction: pick_input selects one input to value, route_output sends route_value to one output.",
|
||||
"index": "Index used by SxCP Index Switch. For Loop Start outputs one_based indexes by default.",
|
||||
"index_base": "one_based means index 1 selects input_1. zero_based means index 0 selects input_1.",
|
||||
"missing_behavior": "What to do when the requested switch input is not connected: use fallback, output none, clamp, or wrap.",
|
||||
"fallback": "Optional value used by SxCP Index Switch when the requested input is missing and missing_behavior is fallback.",
|
||||
"route_value": "Value routed to output_N when mode is route_output.",
|
||||
"clothing": "Built-in clothing density for legacy direct generation. random picks full/minimal from the seeded row.",
|
||||
"poses": "Built-in pose pool for legacy direct generation. random picks standard/evocative from the seeded row.",
|
||||
"backside_bias": "Legacy bias toward rear/backside poses where that category supports it.",
|
||||
"minimal_clothing_ratio": "Legacy weighted ratio override. -1 keeps the category/profile default.",
|
||||
"standard_pose_ratio": "Legacy weighted ratio override. -1 keeps the category/profile default.",
|
||||
"profile": "Generation profile preset for broad style, clothing, pose, and expression defaults.",
|
||||
"clothing_override": "Override the profile clothing setting, or leave profile_default.",
|
||||
"poses_override": "Override the profile pose setting, or leave profile_default.",
|
||||
"trigger_policy": "Controls whether the profile prepends the trigger token.",
|
||||
"cast_mode": "Preset cast shape. Custom counts are used when the preset allows them.",
|
||||
"women_weights": "Comma-separated count weights. First value maps to women_start_count, second to +1, and so on.",
|
||||
"men_weights": "Comma-separated count weights. First value maps to men_start_count, second to +1, and so on.",
|
||||
"women_start_count": "Woman count represented by the first women_weights value.",
|
||||
"men_start_count": "Man count represented by the first men_weights value.",
|
||||
"empty_behavior": "What to do if the weighted pick selects zero women and zero men.",
|
||||
"preset": "Category preset for common workflow lanes.",
|
||||
"camera_mode": "Camera style preset.",
|
||||
"shot_size": "How much of the body/frame should be visible.",
|
||||
"angle": "Camera angle relative to the subject.",
|
||||
"lens": "Lens wording to include in the prompt.",
|
||||
"distance": "Camera distance wording.",
|
||||
"orientation": "Horizontal/vertical framing wording.",
|
||||
"phone_visibility": "Whether the prompt mentions a visible/hidden phone.",
|
||||
"priority": "How strictly the prompt should enforce the camera wording.",
|
||||
"camera_detail": "off omits camera text, compact keeps one line, full emits detailed camera wording.",
|
||||
"subject_focus": "Optional camera focus phrase, such as face/body/contact emphasis.",
|
||||
"strict_excludes": "When enabled, only selected ethnicity groups are used. When off, selections act more like soft includes.",
|
||||
"min_age": "Minimum adult age in this custom age pool.",
|
||||
"max_age": "Maximum adult age in this custom age pool.",
|
||||
"softcore_source": "Softcore outfit source for this character. custom reads custom_softcore_outfits.",
|
||||
"hardcore_state": "Hardcore clothing/body exposure state for this character.",
|
||||
"softcore_expression_enabled": "Enable expression text in the softcore prompt.",
|
||||
"hardcore_expression_enabled": "Enable expression text in the hardcore prompt.",
|
||||
"flow": "Loop flow-control socket. Wire from the matching loop start node.",
|
||||
"collection_mode": "How the loop end collects per-iteration values.",
|
||||
"skip_none": "Do not add empty values to the collection.",
|
||||
"collected": "Current accumulated value carried through the loop.",
|
||||
"collect_value": "Value captured from the current loop iteration.",
|
||||
"a": "First integer/boolean helper input.",
|
||||
"b": "Second integer/boolean helper input.",
|
||||
}
|
||||
|
||||
NODE_INPUT_TOOLTIPS = {
|
||||
"SxCPGlobalSeed": {
|
||||
"global_seed": "Master reproducibility seed. Connect seed to generator seed and seed_config to seed_config so random choices can be recreated exactly.",
|
||||
},
|
||||
"SxCPSeedControl": {
|
||||
"category_seed_mode": "auto/follow_main follows the main seed; fixed uses category_seed; random rerolls this axis at queue time while the field value stays unchanged.",
|
||||
"subcategory_seed_mode": "Controls which subcategory is selected. Change this to switch oral vs penetration when both are allowed.",
|
||||
"content_seed_mode": "Controls item/outfit content for non-pose categories.",
|
||||
"person_seed_mode": "Controls generated character appearance unless a slot seed overrides it.",
|
||||
"scene_seed_mode": "Controls location/scene selection.",
|
||||
"pose_seed_mode": "Controls pose/item selection for pose categories, including hardcore positions.",
|
||||
"role_seed_mode": "Controls role assignment and secondary action details.",
|
||||
"expression_seed_mode": "Controls selected expression text.",
|
||||
"composition_seed_mode": "Controls framing/composition text.",
|
||||
},
|
||||
"SxCPSeedLocker": {
|
||||
"base_seed": "Master seed for the locked result. Use the same value as the generator seed for simplest reproduction.",
|
||||
"reroll_axis": "Choose the one axis to change while the rest stays locked. Use pose for sexual pose, scene for location, person for appearance.",
|
||||
"reroll_seed": "Seed for the selected axis only. Leave -1 to derive a stable reroll from base_seed.",
|
||||
},
|
||||
"SxCPCastBias": {
|
||||
"seed": "Fixed cast-bias seed. Use -1 for a fresh cast each queue, or connect Global Seed/Seed Locker through seed_config.",
|
||||
"seed_config": "Optional seed config. The category seed controls weighted cast selection.",
|
||||
"women_weights": "Example with women_start_count=1: 0.6,0.25,0.1 means 60% one woman, 25% two women, 10% three women.",
|
||||
"men_weights": "Example with men_start_count=0: 0.5,0.35,0.1 means 50% no man, 35% one man, 10% two men.",
|
||||
"empty_behavior": "Prevents accidental empty casts when both weighted pools pick zero.",
|
||||
},
|
||||
"SxCPSDXLBucketSize": {
|
||||
"orientation": "Bucket orientation filter. any uses the full table; portrait/square/landscape restrict random selection.",
|
||||
"seed": "Fixed bucket seed. Use -1 for a fresh random bucket each queue, or connect Global Seed for reproducible sizes.",
|
||||
"row_number": "Deterministic row offset for the bucket. With a fixed seed, changing this advances the bucket choice.",
|
||||
"bucket_index": "0=random. 1+ selects that bucket position inside the selected orientation pool and ignores seed.",
|
||||
"seed_config": "Optional seed config. The composition seed controls bucket choice, so Seed Locker can keep sizes fixed while rerolling pose/person.",
|
||||
},
|
||||
"SxCPKrea2ResolutionSelector": {
|
||||
"megapixels": "Target megapixel preset. If it cannot fit the aspect ratio under the 2K Krea2 Turbo limit, the node clamps to the maximum valid size.",
|
||||
"aspect_ratio": "Krea API ratios are listed first; local-only helper ratios like 8:9 are included after them.",
|
||||
},
|
||||
"SxCPCameraControl": {
|
||||
"camera_mode": "Camera style preset. Use from_camera_config in Insta/OF options to consume this.",
|
||||
"priority": "locked makes the camera wording strict; soft_hint allows the model more freedom.",
|
||||
"camera_detail": "off omits camera text, compact keeps one short line, full emits detailed camera constraints.",
|
||||
"phone_visibility": "Use phone_hidden or suppress_phone_visibility when you do not want 'phone hidden' text in prompts.",
|
||||
},
|
||||
"SxCPCameraOrbitControl": {
|
||||
"enabled": "When false, outputs an empty camera config so downstream nodes fall back to their own camera settings.",
|
||||
"horizontal_angle": "Orbit angle in degrees. 0=front, 90=right side, 180=back, 270=left side.",
|
||||
"vertical_angle": "Camera elevation. Negative looks up, positive looks down.",
|
||||
"zoom": "Maps to distance/framing when framing is from_zoom.",
|
||||
"framing": "How zoom should be translated into shot size/distance wording.",
|
||||
"include_degrees": "Include numeric degree wording in addition to human camera direction.",
|
||||
},
|
||||
"SxCPQwenCameraTranslator": {
|
||||
"qwen_prompt": "Camera prompt from Qwen MultiAngle, for example '<sks> front-right quarter view eye-level shot medium shot'.",
|
||||
"camera_info": "Optional structured camera_info from Qwen MultiAngle. Used before qwen_prompt when prefer_camera_info is true.",
|
||||
"prefer_camera_info": "Use structured camera_info values when available instead of parsing the text prompt.",
|
||||
"phone_visibility": "Leave auto when using Qwen/Orbit camera prompts unless you explicitly want phone visibility text.",
|
||||
"suppress_phone_visibility": "Avoid adding phone visibility text unless you explicitly set a phone option.",
|
||||
},
|
||||
"SxCPHardcorePositionPool": {
|
||||
"family": "Restrict the broad hardcore family. Use any when you want oral and penetration to both be possible.",
|
||||
"combine_mode": "replace discards incoming position choices; add merges these choices with the incoming config.",
|
||||
"hardcore_position_config": "Optional incoming config. Usually connect previous Position Pool here only when chaining pools.",
|
||||
},
|
||||
"SxCPHardcoreActionFilter": {
|
||||
"focus": "keep_pool preserves/broadens the incoming pool; *_only modes force one action family.",
|
||||
"allow_toys": "Allow toy/strap-on wording in hardcore actions.",
|
||||
"allow_double": "Allow double-penetration or second-contact wording.",
|
||||
"allow_penetration": "Allow vaginal/penetrative sex subcategories.",
|
||||
"allow_foreplay": "Allow hardcore teasing/foreplay setup actions such as kissing, caressing, breast/face touching, and undressing.",
|
||||
"allow_interaction": "Allow non-act interaction pools such as body worship, clothing transitions, guidance, camera presentation, watching, and aftercare.",
|
||||
"allow_manual": "Allow manual stimulation pools such as fingering, clit rubbing, and mutual masturbation.",
|
||||
"allow_oral": "Allow oral sex subcategories.",
|
||||
"allow_outercourse": "Allow non-penetrative penis-contact acts such as boobjob/titjob, footjob, penis licking, and testicle sucking.",
|
||||
"allow_anal": "Allow anal subcategories.",
|
||||
"allow_climax": "Allow cumshot/climax aftermath subcategories.",
|
||||
},
|
||||
"SxCPInstaOFOptions": {
|
||||
"softcore_cast": "solo keeps softcore focused on Woman A; same_as_hardcore includes the same cast as the hardcore prompt.",
|
||||
"hardcore_cast": "use_counts reads hardcore_women_count/hardcore_men_count; presets set the counts automatically.",
|
||||
"softcore_level": "Controls the soft prompt exposure/outfit level.",
|
||||
"hardcore_level": "Controls how explicit the hardcore prompt style is.",
|
||||
"platform_style": "Instagram/OnlyFans styling bias for the dual prompt pair.",
|
||||
"continuity": "Whether the softcore and hardcore prompts share the room/creator setup.",
|
||||
"hardcore_clothing_continuity": "How clothing carries from softcore to hardcore. explicit_nude avoids outfit references so clothing tokens do not fight nudity.",
|
||||
"softcore_camera_mode": "Camera mode for the softcore prompt, or from_camera_config.",
|
||||
"hardcore_camera_mode": "Camera mode for the hardcore prompt. same_as_softcore reuses the softcore setting.",
|
||||
"camera_detail": "Global camera verbosity for the pair unless a camera config overrides it.",
|
||||
"hardcore_detail_density": "How dense the hardcore action sentence should be in the Krea formatter.",
|
||||
},
|
||||
"SxCPInstaOFPromptPair": {
|
||||
"options_json": "Options from SxCP Insta/OF Options. If empty, defaults are used.",
|
||||
"ethnicity": "Fallback ethnicity when no filter/ethnicity list or character slots are connected.",
|
||||
"figure": "Fallback figure bias when no character slot overrides it.",
|
||||
},
|
||||
"SxCPPromptBuilderFromConfigs": {
|
||||
"seed": "Main seed. Connect Seed Config for per-axis control.",
|
||||
},
|
||||
"SxCPCharacterSlot": {
|
||||
"subject_type": "Choose whether this slot creates a woman or man. Man is required for POV presence.",
|
||||
"label": "auto_chain uses the next free label for that subject type based on incoming cast order.",
|
||||
"character_cast": "Optional incoming cast from the previous slot. Output this node's character_cast to the next slot or final generator.",
|
||||
"presence_mode": "POV only has an effect for men; it makes the man implied by camera/body cues instead of fully described.",
|
||||
"characteristics": "Optional controlled-random pool from age/body/eye/clothing nodes. Connected pools override the matching random choices.",
|
||||
"hair_config": "Optional controlled-random hair pool. Chain Hair Length, Hair Color, and Hair Style before this slot.",
|
||||
},
|
||||
"SxCPWomanSlot": {
|
||||
"label": "auto_chain uses the next free Woman label based on incoming cast order.",
|
||||
"character_cast": "Optional incoming cast from the previous slot. Output this node's character_cast to the next slot or final generator.",
|
||||
"figure_bias": "Broad woman body bias. Body Pool or manual body wording can narrow the actual phrase.",
|
||||
"characteristics": "Optional controlled-random pool from age/body/eye/clothing nodes. Connected pools override the matching random choices.",
|
||||
"hair_config": "Optional controlled-random hair pool. Chain Hair Length, Hair Color, and Hair Style before this slot.",
|
||||
},
|
||||
"SxCPManSlot": {
|
||||
"label": "auto_chain uses the next free Man label based on incoming cast order.",
|
||||
"character_cast": "Optional incoming cast from the previous slot. Output this node's character_cast to the next slot or final generator.",
|
||||
"presence_mode": "visible describes the man normally; pov makes him the first-person viewer and suppresses most man descriptor text.",
|
||||
"descriptor_detail": "compact or minimal usually works better for non-primary men; full makes the man as detailed as the creator.",
|
||||
"characteristics": "Optional controlled-random pool from age/body/eye/clothing nodes. Connected pools override the matching random choices.",
|
||||
"hair_config": "Optional controlled-random hair pool. Chain Hair Length, Hair Color, and Hair Style before this slot.",
|
||||
},
|
||||
"SxCPCharacterClothing": {
|
||||
"softcore_source": "Built-in softcore outfit pool. custom reads custom_softcore_outfits; no_change leaves the current pool untouched.",
|
||||
"hardcore_state": "Hardcore exposure pool. explicit_nude avoids outfit references; partially_removed can intentionally keep clothing words.",
|
||||
"characteristics": "Incoming characteristic pool to extend or replace with clothing choices.",
|
||||
},
|
||||
"SxCPCharacterProfileSave": {
|
||||
"profile_name": "Profile filename stem. Saving requires save_now=true.",
|
||||
"metadata_json": "Use generator metadata to save the currently generated character without regenerating it.",
|
||||
"character_slot": "Use this when saving a configured slot directly.",
|
||||
},
|
||||
"SxCPCharacterProfileLoad": {
|
||||
"enabled": "When false, outputs an empty profile and leaves downstream generation unchanged.",
|
||||
"override_age": "Optional loaded-profile override. Empty keeps the profile value.",
|
||||
"override_body": "Optional body override. Empty keeps the profile value.",
|
||||
"override_descriptor_detail": "Override descriptor verbosity while keeping the rest of the loaded profile.",
|
||||
},
|
||||
"SxCPKrea2Formatter": {
|
||||
"metadata_json": "Best input for Krea2 formatting because it preserves cast, camera, and hardcore action metadata.",
|
||||
"preserve_trigger": "Reminder: Krea2 formatting is intended to remove training/style triggers. Leave false unless you intentionally want a raw text trigger preserved.",
|
||||
"source_text": "Raw prompt fallback. Known trigger tokens are stripped by default for Krea2.",
|
||||
},
|
||||
"SxCPSDXLFormatter": {
|
||||
"metadata_json": "Best input for SDXL tag formatting because it preserves cast, camera, outfit, and explicit action metadata.",
|
||||
"formatter_profile": "High-level formatter defaults. manual_controls keeps style_preset and quality_preset authoritative.",
|
||||
"style_preset": "Positive style anchor preset. flat_vector_pony matches the old SDXL tag style.",
|
||||
"quality_preset": "Quality/score tag tail for SDXL or Pony-style checkpoints.",
|
||||
"custom_style": "Optional replacement for the style preset. Leave empty to use style_preset.",
|
||||
"custom_quality": "Optional replacement for the quality preset. Leave empty to use quality_preset.",
|
||||
"nude_weight": "Weight used when explicit nude/body exposure tags are inferred.",
|
||||
},
|
||||
"SxCPCaptionNaturalizer": {
|
||||
"metadata_json": "Best input for training captions because it preserves structured generator details.",
|
||||
"caption_profile": "Preset behavior for the caption rewrite. manual_controls keeps detail/style/include-trigger widgets authoritative.",
|
||||
"style_policy": "drop_style_tail removes generation/style boilerplate; keep_style_terms preserves more of it.",
|
||||
"include_trigger": "Keep this true for LoRA/training captions so the trigger token is learned.",
|
||||
},
|
||||
"SxCPForLoopStart": {
|
||||
"index": "Output loop index. First generated index is skip + 1.",
|
||||
"collected": "Current accumulated value carried through the loop.",
|
||||
},
|
||||
"SxCPLoopAppend": {
|
||||
"mode": "auto_batch tries tensor/latent batching first, then falls back to a list.",
|
||||
},
|
||||
"SxCPAccumulator": {
|
||||
"image_batch_mode": "same_size_only keeps incompatible sizes separate; resize_to_first forces one image batch.",
|
||||
},
|
||||
"SxCPAccumulatorPreview": {
|
||||
"store_key": "Use the same key as the Accumulator store. Prefer wiring store_key_input from Accumulator to avoid mismatches.",
|
||||
"view_mode": "grid shows all entries together; carousel keeps one large image visible for review.",
|
||||
"zoom_level": "Visual preview scale only. It does not resize stored images or saved files.",
|
||||
"delete_action": "Execution-time delete/clear. The preview JS buttons can also delete without changing this widget.",
|
||||
"save_batch": "Saves all current accumulator images when the workflow executes and finished is true.",
|
||||
"clear_after_save": "Clear the in-memory accumulator only after a successful save.",
|
||||
},
|
||||
"SxCPIndexSwitch": {
|
||||
"mode": "pick_input selects input_N as value; route_output sends route_value to output_N.",
|
||||
"index": "Selected input/output index. With one_based, index 1 maps to input_1/output_1.",
|
||||
"missing_behavior": "Controls missing indexes: fallback uses fallback, clamp/wrap select another slot, none returns empty.",
|
||||
"route_value": "Value sent to the selected output_N when mode is route_output.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tooltip_for_input(node_name: str, input_name: str) -> str:
|
||||
node_tooltips = NODE_INPUT_TOOLTIPS.get(node_name, {})
|
||||
if input_name in node_tooltips:
|
||||
return node_tooltips[input_name]
|
||||
if input_name in COMMON_INPUT_TOOLTIPS:
|
||||
return COMMON_INPUT_TOOLTIPS[input_name]
|
||||
if input_name.endswith("_seed_mode"):
|
||||
axis = input_name[: -len("_seed_mode")]
|
||||
return f"How the {axis} seed is resolved: follow the main seed, use the fixed field, or reroll randomly."
|
||||
if input_name.endswith("_seed"):
|
||||
axis = input_name[: -len("_seed")]
|
||||
return f"Fixed {axis} seed value. Used only when the matching seed mode is fixed, or as a fallback for auto modes."
|
||||
if input_name.startswith("include_"):
|
||||
value = input_name[len("include_") :].replace("_", " ")
|
||||
return f"Include {value} in this random pool."
|
||||
if input_name.startswith("initial_value"):
|
||||
return "Carry value passed into the loop body and returned on the matching output."
|
||||
if re.match(r"^input_\d+$", input_name):
|
||||
return "Autoscaling switch input. Connect the last visible input to reveal the next one."
|
||||
if re.match(r"^output_\d+$", input_name):
|
||||
return "Autoscaling routed output. Connect the last visible output to reveal the next one."
|
||||
if input_name.startswith("override_"):
|
||||
return "Optional loaded-profile override. Leave empty or keep_profile to preserve the profile value."
|
||||
return ""
|
||||
|
||||
|
||||
def _copy_input_spec_with_tooltip(input_spec, tooltip: str):
|
||||
if not tooltip or not isinstance(input_spec, tuple):
|
||||
return input_spec
|
||||
if len(input_spec) >= 2 and isinstance(input_spec[1], dict):
|
||||
options = dict(input_spec[1])
|
||||
options.setdefault("tooltip", tooltip)
|
||||
return (input_spec[0], options, *input_spec[2:])
|
||||
if len(input_spec) == 1:
|
||||
return (input_spec[0], {"tooltip": tooltip})
|
||||
return input_spec
|
||||
|
||||
|
||||
def _inject_input_tooltips(input_types: dict, node_name: str) -> dict:
|
||||
patched = dict(input_types)
|
||||
for group_name in ("required", "optional"):
|
||||
group = patched.get(group_name)
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
patched_group = {}
|
||||
for input_name, input_spec in group.items():
|
||||
patched_group[input_name] = _copy_input_spec_with_tooltip(
|
||||
input_spec,
|
||||
_tooltip_for_input(node_name, input_name),
|
||||
)
|
||||
patched[group_name] = patched_group
|
||||
return patched
|
||||
|
||||
|
||||
def install_input_tooltips(node_classes: dict[str, type]) -> None:
|
||||
for node_name, node_class in node_classes.items():
|
||||
original = getattr(node_class, "INPUT_TYPES", None)
|
||||
if original is None or getattr(node_class, "_sxcp_tooltips_installed", False):
|
||||
continue
|
||||
|
||||
def input_types(cls, _original=original, _node_name=node_name):
|
||||
return _inject_input_tooltips(_original(), _node_name)
|
||||
|
||||
node_class.INPUT_TYPES = classmethod(input_types)
|
||||
node_class._sxcp_tooltips_installed = True
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
OUTERCOURSE_BOOBJOB = "boobjob"
|
||||
OUTERCOURSE_TESTICLE = "testicle_sucking"
|
||||
OUTERCOURSE_PENIS_LICKING = "penis_licking"
|
||||
OUTERCOURSE_HANDJOB = "handjob"
|
||||
OUTERCOURSE_FOOTJOB = "footjob"
|
||||
OUTERCOURSE_GENERIC = "generic"
|
||||
|
||||
|
||||
OUTERCOURSE_ACTION_KIND_CHOICES = {
|
||||
OUTERCOURSE_BOOBJOB,
|
||||
OUTERCOURSE_TESTICLE,
|
||||
OUTERCOURSE_PENIS_LICKING,
|
||||
OUTERCOURSE_HANDJOB,
|
||||
OUTERCOURSE_FOOTJOB,
|
||||
OUTERCOURSE_GENERIC,
|
||||
}
|
||||
|
||||
|
||||
def _clean(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 normalize_outercourse_action_kind(value: Any, default: str = OUTERCOURSE_GENERIC) -> str:
|
||||
text = re.sub(r"[^a-z0-9]+", "_", _clean(value).lower()).strip("_")
|
||||
aliases = {
|
||||
"breast_sex": OUTERCOURSE_BOOBJOB,
|
||||
"titjob": OUTERCOURSE_BOOBJOB,
|
||||
"tit_job": OUTERCOURSE_BOOBJOB,
|
||||
"testicle": OUTERCOURSE_TESTICLE,
|
||||
"testicles": OUTERCOURSE_TESTICLE,
|
||||
"ball_licking": OUTERCOURSE_TESTICLE,
|
||||
"balls_licking": OUTERCOURSE_TESTICLE,
|
||||
"balls": OUTERCOURSE_TESTICLE,
|
||||
"penis_lick": OUTERCOURSE_PENIS_LICKING,
|
||||
"penis_tongue": OUTERCOURSE_PENIS_LICKING,
|
||||
"hand_job": OUTERCOURSE_HANDJOB,
|
||||
"two_handed_handjob": OUTERCOURSE_HANDJOB,
|
||||
"foot_job": OUTERCOURSE_FOOTJOB,
|
||||
"feet_job": OUTERCOURSE_FOOTJOB,
|
||||
}
|
||||
text = aliases.get(text, text)
|
||||
return text if text in OUTERCOURSE_ACTION_KIND_CHOICES else default
|
||||
|
||||
|
||||
def infer_outercourse_action_kind(*parts: Any) -> str:
|
||||
text = " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
if not text:
|
||||
return OUTERCOURSE_GENERIC
|
||||
if any(
|
||||
term in text
|
||||
for term in (
|
||||
"boobjob",
|
||||
"titjob",
|
||||
"tit job",
|
||||
"breast sex",
|
||||
"breast-sex",
|
||||
"breasts tightly around",
|
||||
"breasts around",
|
||||
"breasts firmly together",
|
||||
"penis squeezed between both breasts",
|
||||
"penis shaft compressed between breasts",
|
||||
"soft flesh squeezed around the penis",
|
||||
)
|
||||
):
|
||||
return OUTERCOURSE_BOOBJOB
|
||||
if any(
|
||||
term in text
|
||||
for term in (
|
||||
"testicle",
|
||||
"balls licking",
|
||||
"balls-licking",
|
||||
"balls held",
|
||||
"balls close",
|
||||
"balls and mouth",
|
||||
"mouth and tongue on the viewer's balls",
|
||||
"mouth and tongue on the pov viewer's balls",
|
||||
"mouth and tongue licking the viewer's balls",
|
||||
"mouth and tongue licking the pov viewer's balls",
|
||||
)
|
||||
):
|
||||
return OUTERCOURSE_TESTICLE
|
||||
if any(
|
||||
term in text
|
||||
for term in (
|
||||
"penis licking",
|
||||
"penis-licking",
|
||||
"tongue along",
|
||||
"tongue runs along",
|
||||
"tongue running along",
|
||||
"tongue licking",
|
||||
"underside of the penis",
|
||||
)
|
||||
):
|
||||
return OUTERCOURSE_PENIS_LICKING
|
||||
if any(
|
||||
term in text
|
||||
for term in (
|
||||
"handjob",
|
||||
"hand job",
|
||||
"hand wrapped",
|
||||
"hand wraps around",
|
||||
"hand stroking",
|
||||
"both hands stroking",
|
||||
"two-handed",
|
||||
"one hand grips",
|
||||
"one hand wrapped around",
|
||||
)
|
||||
):
|
||||
return OUTERCOURSE_HANDJOB
|
||||
if any(
|
||||
term in text
|
||||
for term in (
|
||||
"footjob",
|
||||
"foot job",
|
||||
"soles",
|
||||
"toes curled",
|
||||
"feet stroking",
|
||||
"feet and penis",
|
||||
"both feet",
|
||||
)
|
||||
):
|
||||
return OUTERCOURSE_FOOTJOB
|
||||
return OUTERCOURSE_GENERIC
|
||||
|
||||
|
||||
def outercourse_context_text(*parts: Any) -> str:
|
||||
return " ".join(_clean(part).lower() for part in parts if _clean(part))
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import pair_camera
|
||||
from . import pair_cast
|
||||
from . import pair_clothing
|
||||
from . import pair_output
|
||||
from . import pair_rows
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import pair_camera
|
||||
import pair_cast
|
||||
import pair_clothing
|
||||
import pair_output
|
||||
import pair_rows
|
||||
|
||||
|
||||
BuildPrompt = Callable[..., dict[str, Any]]
|
||||
AxisRng = Callable[[dict[str, int], str, int, int], Any]
|
||||
Choose = Callable[[Any, list[str]], str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstaPairBuildRequest:
|
||||
row_number: int
|
||||
start_index: int
|
||||
seed: int
|
||||
ethnicity: str
|
||||
figure: str
|
||||
no_plus_women: bool
|
||||
no_black: bool
|
||||
trigger: str
|
||||
prepend_trigger_to_prompt: bool
|
||||
seed_config: str | dict[str, Any] | None = None
|
||||
options_json: str | dict[str, Any] | None = None
|
||||
filter_config: str | dict[str, Any] | None = None
|
||||
camera_config: str | dict[str, Any] | None = None
|
||||
softcore_camera_config: str | dict[str, Any] | None = None
|
||||
hardcore_camera_config: str | dict[str, Any] | None = None
|
||||
character_profile: str | dict[str, Any] | None = ""
|
||||
character_cast: str | dict[str, Any] | list[Any] | None = ""
|
||||
hardcore_position_config: str | dict[str, Any] | None = ""
|
||||
location_config: str | dict[str, Any] | None = ""
|
||||
composition_config: str | dict[str, Any] | None = ""
|
||||
extra_positive: str = ""
|
||||
extra_negative: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstaPairBuildDependencies:
|
||||
default_trigger: str
|
||||
random_subcategory: str
|
||||
soft_negative_base: str
|
||||
hard_negative_base: str
|
||||
camera_detail_choices: list[str] | tuple[str, ...]
|
||||
hardcore_clothing_continuity: dict[str, str]
|
||||
platform_styles: dict[str, str]
|
||||
soft_levels: dict[str, str]
|
||||
hardcore_levels: dict[str, str]
|
||||
parse_options: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
parse_filter_config: Callable[[str | dict[str, Any] | None], dict[str, Any]]
|
||||
parse_seed_config: Callable[[str | dict[str, Any] | None], dict[str, int]]
|
||||
parse_character_cast: Callable[[str | dict[str, Any] | list[Any] | None], list[dict[str, Any]]]
|
||||
character_slot_label_map: Callable[[list[dict[str, Any]]], dict[str, dict[str, Any]]]
|
||||
pov_character_labels: Callable[[dict[str, dict[str, Any]], int], list[str]]
|
||||
softcore_category: Callable[[str], tuple[str, str]]
|
||||
build_prompt: BuildPrompt
|
||||
axis_rng: AxisRng
|
||||
cast_expression_intensity_override: Callable[
|
||||
[float, dict[str, dict[str, Any]], int, int, str],
|
||||
tuple[float | None, str],
|
||||
]
|
||||
context_from_character_slot: Callable[[Any, dict[str, Any], str, str, str, bool, bool], dict[str, Any]]
|
||||
apply_character_context_to_row: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]]
|
||||
disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]]
|
||||
slot_softcore_outfit: Callable[[dict[str, Any] | None, Any], str]
|
||||
softcore_outfit: Callable[[Any, str], str]
|
||||
softcore_pose: Callable[[Any, str], str]
|
||||
softcore_item_prompt_label: Callable[[str], str]
|
||||
pov_prompt_directive: Callable[[list[str]], str]
|
||||
pov_composition_prompt: Callable[[Any, list[str]], str]
|
||||
hardcore_counts: Callable[[dict[str, Any]], tuple[int, int]]
|
||||
character_context_for_label: Callable[
|
||||
[str, dict[str, dict[str, Any]], Any, str, str, bool, bool],
|
||||
tuple[dict[str, Any], dict[str, Any] | None],
|
||||
]
|
||||
slot_is_pov: Callable[[dict[str, Any] | None], bool]
|
||||
choose: Choose
|
||||
camera_config_with_mode: Callable[[str | dict[str, Any] | None, str], dict[str, Any]]
|
||||
camera_directive: Callable[[str | dict[str, Any] | None], tuple[str, dict[str, Any]]]
|
||||
apply_contextual_composition: Callable[[dict[str, Any], str], dict[str, Any]]
|
||||
contextual_composition_prompt: Callable[[Any, Any, str], str]
|
||||
composition_prompt: Callable[[Any], str]
|
||||
camera_scene_directive_for_context: Callable[
|
||||
[Any, Any, str | dict[str, Any] | None, list[str] | None, str],
|
||||
tuple[str, dict[str, Any]],
|
||||
]
|
||||
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str]
|
||||
hardcore_detail_directive: Callable[[Any], str]
|
||||
camera_caption_text: Callable[[dict[str, Any]], str]
|
||||
|
||||
|
||||
def build_insta_of_pair(request: InstaPairBuildRequest, deps: InstaPairBuildDependencies) -> dict[str, Any]:
|
||||
options = deps.parse_options(request.options_json)
|
||||
ethnicity = request.ethnicity
|
||||
figure = request.figure
|
||||
no_plus_women = request.no_plus_women
|
||||
no_black = request.no_black
|
||||
if request.filter_config:
|
||||
filters = deps.parse_filter_config(request.filter_config)
|
||||
ethnicity = filters["ethnicity"]
|
||||
figure = filters["figure"]
|
||||
no_plus_women = filters["no_plus_women"]
|
||||
no_black = filters["no_black"]
|
||||
|
||||
hard_women_count, hard_men_count = deps.hardcore_counts(options)
|
||||
active_trigger = request.trigger.strip() or deps.default_trigger
|
||||
parsed_seed_config = deps.parse_seed_config(request.seed_config)
|
||||
character_slots = deps.parse_character_cast(request.character_cast)
|
||||
character_slot_map = deps.character_slot_label_map(character_slots)
|
||||
pov_character_labels = deps.pov_character_labels(character_slot_map, hard_men_count)
|
||||
softcore_level_key = str(options["softcore_level"])
|
||||
soft_category, soft_subcategory = deps.softcore_category(softcore_level_key)
|
||||
|
||||
row_route = pair_rows.build_insta_pair_rows_result(
|
||||
row_number=request.row_number,
|
||||
start_index=request.start_index,
|
||||
seed=request.seed,
|
||||
active_trigger=active_trigger,
|
||||
parsed_seed_config=parsed_seed_config,
|
||||
options=options,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
character_profile=request.character_profile,
|
||||
character_cast=request.character_cast or "",
|
||||
character_slot_map=character_slot_map,
|
||||
pov_character_labels=pov_character_labels,
|
||||
hard_women_count=hard_women_count,
|
||||
hard_men_count=hard_men_count,
|
||||
soft_category=soft_category,
|
||||
soft_subcategory=soft_subcategory,
|
||||
softcore_level_key=softcore_level_key,
|
||||
hardcore_random_subcategory=deps.random_subcategory,
|
||||
hardcore_position_config=request.hardcore_position_config,
|
||||
location_config=request.location_config or "",
|
||||
composition_config=request.composition_config or "",
|
||||
build_prompt=deps.build_prompt,
|
||||
axis_rng=deps.axis_rng,
|
||||
cast_expression_intensity_override=deps.cast_expression_intensity_override,
|
||||
context_from_character_slot=deps.context_from_character_slot,
|
||||
apply_character_context_to_row=deps.apply_character_context_to_row,
|
||||
disable_row_expression=deps.disable_row_expression,
|
||||
slot_softcore_outfit=deps.slot_softcore_outfit,
|
||||
softcore_outfit=deps.softcore_outfit,
|
||||
softcore_pose=deps.softcore_pose,
|
||||
softcore_item_prompt_label=deps.softcore_item_prompt_label,
|
||||
pov_prompt_directive=deps.pov_prompt_directive,
|
||||
pov_composition_prompt=deps.pov_composition_prompt,
|
||||
)
|
||||
soft_row = row_route.soft_row
|
||||
hard_row = row_route.hard_row
|
||||
hard_content_rng = row_route.hard_content_rng
|
||||
|
||||
cast_context = pair_cast.resolve_insta_pair_cast_context(
|
||||
soft_row=soft_row,
|
||||
options=options,
|
||||
parsed_seed_config=parsed_seed_config,
|
||||
seed=request.seed,
|
||||
row_number=request.row_number,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
hard_women_count=hard_women_count,
|
||||
hard_men_count=hard_men_count,
|
||||
character_slots=character_slots,
|
||||
character_slot_map=character_slot_map,
|
||||
pov_character_labels=pov_character_labels,
|
||||
platform_styles=deps.platform_styles,
|
||||
soft_levels=deps.soft_levels,
|
||||
hardcore_levels=deps.hardcore_levels,
|
||||
axis_rng=deps.axis_rng,
|
||||
character_context_for_label=deps.character_context_for_label,
|
||||
slot_is_pov=deps.slot_is_pov,
|
||||
choose=deps.choose,
|
||||
slot_softcore_outfit=deps.slot_softcore_outfit,
|
||||
)
|
||||
|
||||
camera_route = pair_camera.resolve_insta_pair_camera_result(
|
||||
soft_row=soft_row,
|
||||
hard_row=hard_row,
|
||||
options=options,
|
||||
camera_config=request.camera_config,
|
||||
softcore_camera_config=request.softcore_camera_config,
|
||||
hardcore_camera_config=request.hardcore_camera_config,
|
||||
hard_women_count=hard_women_count,
|
||||
hard_men_count=hard_men_count,
|
||||
pov_character_labels=pov_character_labels,
|
||||
camera_detail_choices=deps.camera_detail_choices,
|
||||
camera_config_with_mode=deps.camera_config_with_mode,
|
||||
camera_directive=deps.camera_directive,
|
||||
apply_contextual_composition=deps.apply_contextual_composition,
|
||||
contextual_composition_prompt=deps.contextual_composition_prompt,
|
||||
composition_prompt=deps.composition_prompt,
|
||||
camera_scene_directive_for_context=deps.camera_scene_directive_for_context,
|
||||
)
|
||||
soft_row = camera_route.soft_row
|
||||
hard_row = camera_route.hard_row
|
||||
hard_scene = camera_route.hard_scene
|
||||
|
||||
character_hardcore_clothing_entries = pair_clothing.character_hardcore_clothing_entries(
|
||||
character_slot_map,
|
||||
hard_women_count,
|
||||
hard_men_count,
|
||||
pov_character_labels,
|
||||
hard_content_rng,
|
||||
deps.slot_hardcore_clothing,
|
||||
)
|
||||
clothing_route = pair_clothing.resolve_hardcore_pair_clothing_result(
|
||||
hard_row=hard_row,
|
||||
mode=options["hardcore_clothing_continuity"],
|
||||
softcore_outfit=soft_row["item"],
|
||||
character_hardcore_clothing_entries=character_hardcore_clothing_entries,
|
||||
men_count=hard_men_count,
|
||||
pov_labels=pov_character_labels,
|
||||
rng=hard_content_rng,
|
||||
continuity_map=deps.hardcore_clothing_continuity,
|
||||
choose=deps.choose,
|
||||
)
|
||||
if clothing_route.requires_body_exposure_scene:
|
||||
hard_scene = pair_clothing.body_exposure_scene_text(hard_scene)
|
||||
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
|
||||
hard_row["scene_text"] = hard_scene
|
||||
|
||||
hard_detail_density = options["hardcore_detail_density"]
|
||||
return pair_output.assemble_insta_pair_metadata(
|
||||
active_trigger=active_trigger,
|
||||
prepend_trigger_to_prompt=bool(request.prepend_trigger_to_prompt),
|
||||
extra_positive=request.extra_positive,
|
||||
extra_negative=request.extra_negative,
|
||||
soft_negative_base=deps.soft_negative_base,
|
||||
hard_negative_base=deps.hard_negative_base,
|
||||
options=options,
|
||||
platform_style=cast_context["platform_style"],
|
||||
soft_descriptor_sentence=cast_context["soft_descriptor_sentence"],
|
||||
soft_level=cast_context["soft_level"],
|
||||
soft_cast=cast_context["soft_cast"],
|
||||
soft_cast_presence=cast_context["soft_cast_presence"],
|
||||
soft_cast_styling_sentence=cast_context["soft_cast_styling_sentence"],
|
||||
soft_row=soft_row,
|
||||
soft_camera_scene_sentence=camera_route.soft_camera_scene_sentence,
|
||||
soft_camera_sentence=camera_route.soft_camera_sentence,
|
||||
hard_level=cast_context["hard_level"],
|
||||
hard_cast=cast_context["hard_cast"],
|
||||
cast_descriptor_text=cast_context["cast_descriptor_text"],
|
||||
pov_directive=deps.pov_prompt_directive(pov_character_labels),
|
||||
pov_character_labels=pov_character_labels,
|
||||
hard_clothing_sentence=clothing_route.hardcore_clothing_sentence,
|
||||
hard_row=hard_row,
|
||||
hard_scene=hard_scene,
|
||||
hard_camera_scene_sentence=camera_route.hard_camera_scene_sentence,
|
||||
hard_composition=camera_route.hard_composition,
|
||||
hard_detail_directive=deps.hardcore_detail_directive(hard_detail_density),
|
||||
hard_camera_sentence=camera_route.hard_camera_sentence,
|
||||
descriptor=cast_context["descriptor"],
|
||||
soft_partner_outfit_text=cast_context["soft_partner_outfit_text"],
|
||||
soft_partner_styling=cast_context["soft_partner_styling"],
|
||||
soft_camera_scene_directive=camera_route.soft_camera_scene_directive,
|
||||
soft_camera_config=camera_route.soft_camera_config,
|
||||
soft_camera_directive=camera_route.soft_camera_directive,
|
||||
hard_camera_scene_directive=camera_route.hard_camera_scene_directive,
|
||||
hard_camera_config=camera_route.hard_camera_config,
|
||||
hard_camera_directive=camera_route.hard_camera_directive,
|
||||
camera_caption_text=deps.camera_caption_text,
|
||||
cast_descriptors=cast_context["cast_descriptors"],
|
||||
character_hardcore_clothing_entries=character_hardcore_clothing_entries,
|
||||
default_man_hardcore_clothing_entries=clothing_route.default_man_hardcore_clothing,
|
||||
hard_clothing_state=clothing_route.hardcore_clothing_state,
|
||||
hard_detail_density=hard_detail_density,
|
||||
hard_women_count=hard_women_count,
|
||||
hard_men_count=hard_men_count,
|
||||
character_slots=character_slots,
|
||||
character_slot_map=character_slot_map,
|
||||
)
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
CameraConfigWithMode = Callable[[str | dict[str, Any] | None, str], dict[str, Any]]
|
||||
CameraDirective = Callable[[str | dict[str, Any] | None], tuple[str, dict[str, Any]]]
|
||||
ApplyComposition = Callable[[dict[str, Any], str], dict[str, Any]]
|
||||
CompositionPrompt = Callable[[Any, Any, str], str]
|
||||
CameraSceneDirective = Callable[
|
||||
[Any, Any, str | dict[str, Any] | None, list[str] | None, str],
|
||||
tuple[str, dict[str, Any]],
|
||||
]
|
||||
|
||||
|
||||
def camera_config_with_detail(
|
||||
camera_config: dict[str, Any],
|
||||
camera_detail: str,
|
||||
camera_detail_choices: list[str] | tuple[str, ...],
|
||||
) -> dict[str, Any]:
|
||||
if camera_detail in camera_detail_choices:
|
||||
camera_config["camera_detail"] = camera_detail
|
||||
return camera_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstaPairCameraRoute:
|
||||
soft_row: dict[str, Any]
|
||||
hard_row: dict[str, Any]
|
||||
hard_scene: str
|
||||
hard_composition: str
|
||||
soft_camera_config: dict[str, Any]
|
||||
hard_camera_config: dict[str, Any]
|
||||
soft_camera_directive: str
|
||||
hard_camera_directive: str
|
||||
soft_camera_scene_directive: str
|
||||
hard_camera_scene_directive: str
|
||||
soft_camera_scene_sentence: str
|
||||
hard_camera_scene_sentence: str
|
||||
soft_camera_sentence: str
|
||||
hard_camera_sentence: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"soft_row": self.soft_row,
|
||||
"hard_row": self.hard_row,
|
||||
"hard_scene": self.hard_scene,
|
||||
"hard_composition": self.hard_composition,
|
||||
"soft_camera_config": dict(self.soft_camera_config),
|
||||
"hard_camera_config": dict(self.hard_camera_config),
|
||||
"soft_camera_directive": self.soft_camera_directive,
|
||||
"hard_camera_directive": self.hard_camera_directive,
|
||||
"soft_camera_scene_directive": self.soft_camera_scene_directive,
|
||||
"hard_camera_scene_directive": self.hard_camera_scene_directive,
|
||||
"soft_camera_scene_sentence": self.soft_camera_scene_sentence,
|
||||
"hard_camera_scene_sentence": self.hard_camera_scene_sentence,
|
||||
"soft_camera_sentence": self.soft_camera_sentence,
|
||||
"hard_camera_sentence": self.hard_camera_sentence,
|
||||
}
|
||||
|
||||
|
||||
def resolve_insta_pair_camera_result(
|
||||
*,
|
||||
soft_row: dict[str, Any],
|
||||
hard_row: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
camera_config: str | dict[str, Any] | None,
|
||||
softcore_camera_config: str | dict[str, Any] | None,
|
||||
hardcore_camera_config: str | dict[str, Any] | None,
|
||||
hard_women_count: int,
|
||||
hard_men_count: int,
|
||||
pov_character_labels: list[str],
|
||||
camera_detail_choices: list[str] | tuple[str, ...],
|
||||
camera_config_with_mode: CameraConfigWithMode,
|
||||
camera_directive: CameraDirective,
|
||||
apply_contextual_composition: ApplyComposition,
|
||||
contextual_composition_prompt: CompositionPrompt,
|
||||
composition_prompt: Callable[[Any], str],
|
||||
camera_scene_directive_for_context: CameraSceneDirective,
|
||||
) -> InstaPairCameraRoute:
|
||||
hard_camera_mode = str(options["hardcore_camera_mode"])
|
||||
soft_camera_source = softcore_camera_config or camera_config
|
||||
hard_camera_source = hardcore_camera_config or camera_config
|
||||
if hard_camera_mode == "same_as_softcore":
|
||||
hard_camera_mode = str(options["softcore_camera_mode"])
|
||||
hard_camera_source = soft_camera_source
|
||||
|
||||
soft_camera_config_dict = camera_config_with_mode(soft_camera_source, str(options["softcore_camera_mode"]))
|
||||
hard_camera_config_dict = camera_config_with_mode(hard_camera_source, hard_camera_mode)
|
||||
soft_camera_config_dict = camera_config_with_detail(
|
||||
soft_camera_config_dict,
|
||||
str(options["camera_detail"]),
|
||||
camera_detail_choices,
|
||||
)
|
||||
hard_camera_config_dict = camera_config_with_detail(
|
||||
hard_camera_config_dict,
|
||||
str(options["camera_detail"]),
|
||||
camera_detail_choices,
|
||||
)
|
||||
soft_camera_directive, soft_camera_config_dict = camera_directive(soft_camera_config_dict)
|
||||
hard_camera_directive, hard_camera_config_dict = camera_directive(hard_camera_config_dict)
|
||||
|
||||
soft_subject_kind = "woman" if options["softcore_cast"] == "solo" else "subjects"
|
||||
hard_subject_kind = "couple" if hard_women_count + hard_men_count == 2 else "subjects"
|
||||
soft_row = apply_contextual_composition(soft_row, soft_subject_kind)
|
||||
hard_row = apply_contextual_composition(hard_row, hard_subject_kind)
|
||||
|
||||
hard_scene = soft_row["scene_text"] if options["continuity"] == "same_creator_same_room" else hard_row["scene_text"]
|
||||
if hard_scene != hard_row.get("scene_text"):
|
||||
hard_row["source_scene_text"] = hard_row.get("source_scene_text") or hard_row.get("scene_text", "")
|
||||
hard_row["scene_text"] = hard_scene
|
||||
|
||||
hard_composition = contextual_composition_prompt(hard_scene, hard_row["composition"], hard_subject_kind)
|
||||
if hard_composition != hard_row["composition"]:
|
||||
hard_row["source_composition"] = hard_row.get("source_composition") or hard_row["composition"]
|
||||
hard_row["composition"] = hard_composition
|
||||
hard_row["composition_prompt"] = composition_prompt(hard_composition)
|
||||
|
||||
soft_pov_camera_labels = pov_character_labels if options["softcore_cast"] == "same_as_hardcore" else []
|
||||
soft_camera_scene_directive, soft_camera_config_dict = camera_scene_directive_for_context(
|
||||
soft_row.get("scene_text"),
|
||||
soft_row.get("composition"),
|
||||
soft_camera_config_dict,
|
||||
soft_pov_camera_labels,
|
||||
soft_subject_kind,
|
||||
)
|
||||
hard_camera_scene_directive, hard_camera_config_dict = camera_scene_directive_for_context(
|
||||
hard_scene,
|
||||
hard_composition,
|
||||
hard_camera_config_dict,
|
||||
pov_character_labels,
|
||||
hard_subject_kind,
|
||||
)
|
||||
|
||||
if soft_pov_camera_labels:
|
||||
soft_camera_directive = ""
|
||||
if pov_character_labels:
|
||||
hard_camera_directive = ""
|
||||
|
||||
soft_row["camera_config"] = soft_camera_config_dict
|
||||
soft_row["camera_directive"] = soft_camera_directive
|
||||
soft_row["camera_scene_directive"] = soft_camera_scene_directive
|
||||
hard_row["camera_config"] = hard_camera_config_dict
|
||||
hard_row["camera_directive"] = hard_camera_directive
|
||||
hard_row["camera_scene_directive"] = hard_camera_scene_directive
|
||||
|
||||
return InstaPairCameraRoute(
|
||||
soft_row=soft_row,
|
||||
hard_row=hard_row,
|
||||
hard_scene=hard_scene,
|
||||
hard_composition=hard_composition,
|
||||
soft_camera_config=soft_camera_config_dict,
|
||||
hard_camera_config=hard_camera_config_dict,
|
||||
soft_camera_directive=soft_camera_directive,
|
||||
hard_camera_directive=hard_camera_directive,
|
||||
soft_camera_scene_directive=soft_camera_scene_directive,
|
||||
hard_camera_scene_directive=hard_camera_scene_directive,
|
||||
soft_camera_scene_sentence=f"{soft_camera_scene_directive} " if soft_camera_scene_directive else "",
|
||||
hard_camera_scene_sentence=f"{hard_camera_scene_directive} " if hard_camera_scene_directive else "",
|
||||
soft_camera_sentence=f"Camera control: {soft_camera_directive} " if soft_camera_directive else "",
|
||||
hard_camera_sentence=f"Camera control: {hard_camera_directive} " if hard_camera_directive else "",
|
||||
)
|
||||
|
||||
|
||||
def resolve_insta_pair_camera(
|
||||
*,
|
||||
soft_row: dict[str, Any],
|
||||
hard_row: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
camera_config: str | dict[str, Any] | None,
|
||||
softcore_camera_config: str | dict[str, Any] | None,
|
||||
hardcore_camera_config: str | dict[str, Any] | None,
|
||||
hard_women_count: int,
|
||||
hard_men_count: int,
|
||||
pov_character_labels: list[str],
|
||||
camera_detail_choices: list[str] | tuple[str, ...],
|
||||
camera_config_with_mode: CameraConfigWithMode,
|
||||
camera_directive: CameraDirective,
|
||||
apply_contextual_composition: ApplyComposition,
|
||||
contextual_composition_prompt: CompositionPrompt,
|
||||
composition_prompt: Callable[[Any], str],
|
||||
camera_scene_directive_for_context: CameraSceneDirective,
|
||||
) -> dict[str, Any]:
|
||||
return resolve_insta_pair_camera_result(
|
||||
soft_row=soft_row,
|
||||
hard_row=hard_row,
|
||||
options=options,
|
||||
camera_config=camera_config,
|
||||
softcore_camera_config=softcore_camera_config,
|
||||
hardcore_camera_config=hardcore_camera_config,
|
||||
hard_women_count=hard_women_count,
|
||||
hard_men_count=hard_men_count,
|
||||
pov_character_labels=pov_character_labels,
|
||||
camera_detail_choices=camera_detail_choices,
|
||||
camera_config_with_mode=camera_config_with_mode,
|
||||
camera_directive=camera_directive,
|
||||
apply_contextual_composition=apply_contextual_composition,
|
||||
contextual_composition_prompt=contextual_composition_prompt,
|
||||
composition_prompt=composition_prompt,
|
||||
camera_scene_directive_for_context=camera_scene_directive_for_context,
|
||||
).as_dict()
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import cast_context as cast_context_policy
|
||||
from . import character_profile as character_profile_policy
|
||||
from . import pair_clothing
|
||||
from . import pair_options
|
||||
from . import softcore_text_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import cast_context as cast_context_policy
|
||||
import character_profile as character_profile_policy
|
||||
import pair_clothing
|
||||
import pair_options
|
||||
import softcore_text_policy
|
||||
|
||||
|
||||
AxisRng = Callable[[dict[str, int], str, int, int], Any]
|
||||
Choose = Callable[[Any, list[str]], str]
|
||||
CharacterContextForLabel = Callable[
|
||||
[str, dict[str, dict[str, Any]], Any, str, str, bool, bool],
|
||||
tuple[dict[str, Any], dict[str, Any] | None],
|
||||
]
|
||||
CharacterSlotLabelMap = Callable[[list[dict[str, Any]]], dict[str, dict[str, Any]]]
|
||||
ParseCharacterCast = Callable[[str | dict[str, Any] | list[Any] | None], list[dict[str, Any]]]
|
||||
SlotIsPov = Callable[[dict[str, Any] | None], bool]
|
||||
SlotSoftcoreOutfit = Callable[[dict[str, Any] | None, Any], str]
|
||||
|
||||
|
||||
def cast_summary_phrase(women_count: int, men_count: int) -> str:
|
||||
return cast_context_policy.cast_summary_phrase(women_count, men_count)
|
||||
|
||||
|
||||
def insta_descriptor_from_row(row: dict[str, Any]) -> str:
|
||||
return character_profile_policy.descriptor_from_parts(
|
||||
"woman",
|
||||
row.get("age_band") or row.get("age"),
|
||||
row.get("body_phrase"),
|
||||
row.get("skin"),
|
||||
row.get("hair"),
|
||||
row.get("eyes"),
|
||||
row.get("descriptor_detail"),
|
||||
)
|
||||
|
||||
|
||||
def insta_descriptor_from_context(context: dict[str, Any]) -> str:
|
||||
subject = str(context.get("subject") or context.get("subject_type") or "person").strip()
|
||||
return character_profile_policy.descriptor_from_parts(
|
||||
subject,
|
||||
context.get("age"),
|
||||
context.get("body_phrase"),
|
||||
context.get("skin"),
|
||||
context.get("hair"),
|
||||
context.get("eyes"),
|
||||
context.get("descriptor_detail"),
|
||||
)
|
||||
|
||||
|
||||
def prompt_cast_descriptors(text: str) -> str:
|
||||
return str(text or "").replace("Woman A / primary creator:", "Woman A:")
|
||||
|
||||
|
||||
def cast_descriptor_entries_from_slots(
|
||||
*,
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
character_slots: list[dict[str, Any]],
|
||||
character_slot_map: dict[str, dict[str, Any]],
|
||||
primary_descriptor: str = "",
|
||||
axis_rng: AxisRng,
|
||||
character_context_for_label: CharacterContextForLabel,
|
||||
slot_is_pov: SlotIsPov,
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
rng = axis_rng(seed_config, "person", seed, row_number + 997)
|
||||
descriptors: list[str] = []
|
||||
for index in range(max(0, women_count)):
|
||||
label = f"Woman {chr(ord('A') + index)}"
|
||||
if index == 0 and primary_descriptor:
|
||||
descriptors.append(f"Woman A / primary creator: {primary_descriptor}")
|
||||
continue
|
||||
context, _slot = character_context_for_label(
|
||||
label,
|
||||
character_slot_map,
|
||||
rng,
|
||||
ethnicity,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
)
|
||||
descriptors.append(f"{label}: {insta_descriptor_from_context(context)}")
|
||||
for index in range(max(0, men_count)):
|
||||
label = f"Man {chr(ord('A') + index)}"
|
||||
if slot_is_pov(character_slot_map.get(label)):
|
||||
continue
|
||||
context, _slot = character_context_for_label(
|
||||
label,
|
||||
character_slot_map,
|
||||
rng,
|
||||
ethnicity,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
)
|
||||
descriptors.append(f"{label}: {insta_descriptor_from_context(context)}")
|
||||
return descriptors, character_slots
|
||||
|
||||
|
||||
def cast_descriptor_entries(
|
||||
*,
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
character_cast: str | dict[str, Any] | list[Any] | None = "",
|
||||
primary_descriptor: str = "",
|
||||
parse_character_cast: ParseCharacterCast,
|
||||
character_slot_label_map: CharacterSlotLabelMap,
|
||||
axis_rng: AxisRng,
|
||||
character_context_for_label: CharacterContextForLabel,
|
||||
slot_is_pov: SlotIsPov,
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
slots = parse_character_cast(character_cast)
|
||||
label_map = character_slot_label_map(slots)
|
||||
return cast_descriptor_entries_from_slots(
|
||||
seed_config=seed_config,
|
||||
seed=seed,
|
||||
row_number=row_number,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
women_count=women_count,
|
||||
men_count=men_count,
|
||||
character_slots=slots,
|
||||
character_slot_map=label_map,
|
||||
primary_descriptor=primary_descriptor,
|
||||
axis_rng=axis_rng,
|
||||
character_context_for_label=character_context_for_label,
|
||||
slot_is_pov=slot_is_pov,
|
||||
)
|
||||
|
||||
|
||||
def softcore_partner_styling(
|
||||
*,
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
pov_labels: list[str] | None,
|
||||
label_map: dict[str, dict[str, Any]] | None,
|
||||
axis_rng: AxisRng,
|
||||
choose: Choose,
|
||||
slot_softcore_outfit: SlotSoftcoreOutfit,
|
||||
) -> dict[str, Any]:
|
||||
content_rng = axis_rng(seed_config, "content", seed, row_number + 421)
|
||||
pose_rng = axis_rng(seed_config, "pose", seed, row_number + 421)
|
||||
pov_set = set(pov_labels or [])
|
||||
outfits: list[str] = []
|
||||
for index in range(max(0, women_count - 1)):
|
||||
label = chr(ord("B") + index)
|
||||
full_label = f"Woman {label}"
|
||||
outfit = slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or choose(
|
||||
content_rng,
|
||||
pair_options.INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS,
|
||||
)
|
||||
sentence = pair_clothing.softcore_outfit_sentence(full_label, outfit)
|
||||
if sentence:
|
||||
outfits.append(sentence)
|
||||
for index in range(max(0, men_count)):
|
||||
label = chr(ord("A") + index)
|
||||
full_label = f"Man {label}"
|
||||
if full_label in pov_set:
|
||||
continue
|
||||
outfit = slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or choose(
|
||||
content_rng,
|
||||
pair_options.INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS,
|
||||
)
|
||||
sentence = pair_clothing.softcore_outfit_sentence(full_label, outfit)
|
||||
if sentence:
|
||||
outfits.append(sentence)
|
||||
return {
|
||||
"outfits": outfits,
|
||||
"pose": choose(pose_rng, pair_options.SOFTCORE_CAST_POSES),
|
||||
}
|
||||
|
||||
|
||||
def resolve_insta_pair_cast_context(
|
||||
*,
|
||||
soft_row: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
parsed_seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
hard_women_count: int,
|
||||
hard_men_count: int,
|
||||
character_slots: list[dict[str, Any]],
|
||||
character_slot_map: dict[str, dict[str, Any]],
|
||||
pov_character_labels: list[str],
|
||||
platform_styles: dict[str, str],
|
||||
soft_levels: dict[str, str],
|
||||
hardcore_levels: dict[str, str],
|
||||
axis_rng: AxisRng,
|
||||
character_context_for_label: CharacterContextForLabel,
|
||||
slot_is_pov: SlotIsPov,
|
||||
choose: Choose,
|
||||
slot_softcore_outfit: SlotSoftcoreOutfit,
|
||||
) -> dict[str, Any]:
|
||||
descriptor = insta_descriptor_from_row(soft_row)
|
||||
cast_descriptors, _descriptor_slots = cast_descriptor_entries_from_slots(
|
||||
seed_config=parsed_seed_config,
|
||||
seed=seed,
|
||||
row_number=row_number,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
women_count=hard_women_count,
|
||||
men_count=hard_men_count,
|
||||
character_slots=character_slots,
|
||||
character_slot_map=character_slot_map,
|
||||
primary_descriptor=descriptor,
|
||||
axis_rng=axis_rng,
|
||||
character_context_for_label=character_context_for_label,
|
||||
slot_is_pov=slot_is_pov,
|
||||
)
|
||||
cast_descriptor_text = prompt_cast_descriptors("; ".join(cast_descriptors))
|
||||
same_softcore_cast = options["softcore_cast"] == "same_as_hardcore"
|
||||
soft_cast_descriptor_text = cast_descriptor_text if same_softcore_cast else f"Woman A: {descriptor}"
|
||||
soft_partner_styling = softcore_partner_styling(
|
||||
seed_config=parsed_seed_config,
|
||||
seed=seed,
|
||||
row_number=row_number,
|
||||
women_count=hard_women_count if same_softcore_cast else 1,
|
||||
men_count=hard_men_count if same_softcore_cast else 0,
|
||||
pov_labels=pov_character_labels if same_softcore_cast else [],
|
||||
label_map=character_slot_map,
|
||||
axis_rng=axis_rng,
|
||||
choose=choose,
|
||||
slot_softcore_outfit=slot_softcore_outfit,
|
||||
)
|
||||
if not same_softcore_cast:
|
||||
soft_partner_styling = {"outfits": [], "pose": ""}
|
||||
soft_partner_outfit_text = "; ".join(soft_partner_styling["outfits"])
|
||||
|
||||
soft_cast = (
|
||||
"solo creator setup with Woman A alone"
|
||||
if options["softcore_cast"] == "solo"
|
||||
else f"soft creator-teaser setup with {cast_summary_phrase(hard_women_count, hard_men_count)}"
|
||||
)
|
||||
soft_cast_presence = (
|
||||
softcore_text_policy.softcore_cast_presence_phrase(
|
||||
same_cast=same_softcore_cast,
|
||||
pov_labels=pov_character_labels,
|
||||
cast_label="Woman A and the listed partners",
|
||||
woman_label="Woman A",
|
||||
)
|
||||
+ ". "
|
||||
)
|
||||
soft_cast_styling_sentence = (
|
||||
f"Partner softcore styling: {soft_partner_outfit_text}. Cast pose: {soft_partner_styling['pose']}. "
|
||||
if same_softcore_cast and soft_partner_outfit_text
|
||||
else ""
|
||||
)
|
||||
hard_cast = cast_summary_phrase(hard_women_count, hard_men_count)
|
||||
soft_descriptor_sentence = (
|
||||
f"Cast descriptors: {soft_cast_descriptor_text}. "
|
||||
if same_softcore_cast
|
||||
else f"Woman A: {descriptor}. "
|
||||
)
|
||||
|
||||
return {
|
||||
"descriptor": descriptor,
|
||||
"cast_descriptors": cast_descriptors,
|
||||
"cast_descriptor_text": cast_descriptor_text,
|
||||
"soft_cast_descriptor_text": soft_cast_descriptor_text,
|
||||
"soft_partner_styling": soft_partner_styling,
|
||||
"soft_partner_outfit_text": soft_partner_outfit_text,
|
||||
"platform_style": platform_styles[options["platform_style"]],
|
||||
"soft_level": soft_levels[options["softcore_level"]],
|
||||
"hard_level": hardcore_levels[options["hardcore_level"]],
|
||||
"soft_cast": soft_cast,
|
||||
"soft_cast_presence": soft_cast_presence,
|
||||
"soft_cast_styling_sentence": soft_cast_styling_sentence,
|
||||
"hard_cast": hard_cast,
|
||||
"soft_descriptor_sentence": soft_descriptor_sentence,
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import item_axis_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import item_axis_policy
|
||||
|
||||
|
||||
WOMAN_LOWER_ACCESS_TERMS = (
|
||||
"penetrat",
|
||||
"thrust",
|
||||
"vaginal",
|
||||
"anal",
|
||||
"rear-entry",
|
||||
"rear entry",
|
||||
"front-and-back",
|
||||
"front and back",
|
||||
"double",
|
||||
"doggy",
|
||||
"missionary",
|
||||
"cowgirl",
|
||||
"straddles",
|
||||
"hips aligned",
|
||||
"penis into",
|
||||
"penis inside",
|
||||
"penis entering",
|
||||
"mouth on her pussy",
|
||||
"mouth pressed to her pussy",
|
||||
"pussy licking",
|
||||
"cunnilingus",
|
||||
"thighs spread",
|
||||
"thighs open",
|
||||
"legs spread",
|
||||
"legs open",
|
||||
"cum on pussy",
|
||||
"cum across her pussy",
|
||||
"cum dripping from pussy",
|
||||
"cum dripping from ass",
|
||||
"cum on belly",
|
||||
"cum on thighs",
|
||||
"cum across her ass",
|
||||
"cum across her lower back",
|
||||
"toy aligned",
|
||||
"second penetration point",
|
||||
)
|
||||
|
||||
WOMAN_UPPER_ACCESS_TERMS = (
|
||||
"boobjob",
|
||||
"titjob",
|
||||
"breast sex",
|
||||
"breasts around",
|
||||
"breasts tightly",
|
||||
"hands pressing both breasts",
|
||||
"breasts together",
|
||||
"cum on breasts",
|
||||
"cum across her breasts",
|
||||
"cum on chest",
|
||||
)
|
||||
|
||||
MAN_LOWER_ACCESS_TERMS = (
|
||||
"penis",
|
||||
"glans",
|
||||
"testicle",
|
||||
"balls",
|
||||
"cumshot",
|
||||
"ejaculat",
|
||||
"semen",
|
||||
"boobjob",
|
||||
"titjob",
|
||||
"breast sex",
|
||||
"footjob",
|
||||
"handjob",
|
||||
"hand job",
|
||||
"hand wrapped",
|
||||
"hand stroking",
|
||||
"blowjob",
|
||||
"fellatio",
|
||||
"penis sucking",
|
||||
"penis in mouth",
|
||||
"mouth on penis",
|
||||
"penis licking",
|
||||
)
|
||||
|
||||
LOWER_BODY_CLOTHING_TERMS = (
|
||||
"panty",
|
||||
"panties",
|
||||
"brief",
|
||||
"briefs",
|
||||
"thong",
|
||||
"bottom",
|
||||
"bottoms",
|
||||
"bodysuit",
|
||||
"teddy",
|
||||
"dress",
|
||||
"skirt",
|
||||
"shorts",
|
||||
"jeans",
|
||||
"trousers",
|
||||
"pants",
|
||||
"bikini",
|
||||
"towel",
|
||||
"sheet",
|
||||
"blanket",
|
||||
)
|
||||
|
||||
UPPER_BODY_CLOTHING_TERMS = (
|
||||
"bra",
|
||||
"cup",
|
||||
"cups",
|
||||
"corset",
|
||||
"bodysuit",
|
||||
"bustier",
|
||||
"top",
|
||||
"camisole",
|
||||
"shirt",
|
||||
"blouse",
|
||||
"bodice",
|
||||
"dress",
|
||||
"robe",
|
||||
"jacket",
|
||||
"sweater",
|
||||
"harness",
|
||||
"chest",
|
||||
"cleavage",
|
||||
"panel",
|
||||
"panels",
|
||||
)
|
||||
|
||||
INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS = [
|
||||
"wears an open button shirt with jeans lowered below the hips for genital access",
|
||||
"wears a fitted tee pushed up with trousers lowered below the hips",
|
||||
"keeps a dark shirt on while pants and underwear are pulled down below the hips",
|
||||
"wears an open overshirt with jeans pushed down at the thighs",
|
||||
"wears a hoodie lifted at the waist with sweatpants lowered below the hips",
|
||||
"wears gym shorts pulled down below the hips with his shirt still on",
|
||||
"keeps a casual shirt on with belt open and pants lowered below the hips",
|
||||
"wears a half-open shirt with lower garments pushed down below the hips",
|
||||
]
|
||||
|
||||
INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE = [
|
||||
"wears an open button shirt with jeans unfastened",
|
||||
"wears a fitted tee with pants opened at the waist",
|
||||
"keeps a dark shirt on with trousers loosened",
|
||||
"wears an open overshirt with jeans partly lowered",
|
||||
"wears gym shorts loose at the waist with a towel nearby",
|
||||
"wears a hoodie lifted at the waist with sweatpants loosened",
|
||||
"wears a casual shirt with belt open and pants partly lowered",
|
||||
"wears a half-open shirt with dark trousers",
|
||||
]
|
||||
|
||||
|
||||
def _clean_pair_punctuation(text: Any) -> str:
|
||||
text = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
text = re.sub(r"\s+([,.;:])", r"\1", text)
|
||||
text = re.sub(r"(?:,\s*){2,}", ", ", text)
|
||||
text = re.sub(r"\.\s*\.", ".", text)
|
||||
text = re.sub(r":\s*\.", ".", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def body_exposure_scene_text(scene: Any) -> str:
|
||||
text = str(scene or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
replacements = (
|
||||
(r",?\s*\bscattered (?:clothes|clothing)\b", ""),
|
||||
(r",?\s*\bfloor clothes\b", ""),
|
||||
(r"\bclothes scattered\b", "soft floor shadows"),
|
||||
(r",?\s*\bscattered lingerie\b", ""),
|
||||
(r",?\s*\blingerie visible nearby\b", ""),
|
||||
(r"\boutfit racks\b", "mirror shelves"),
|
||||
(r"\bcostume racks\b", "mirror shelves"),
|
||||
(r"\bshoe shelves\b", "side shelves"),
|
||||
(r"\bshoes visible\b", "body placement visible"),
|
||||
(r"\bbag and shoes visible\b", "nearby floor edge visible"),
|
||||
(r"\bshoes and bag visible\b", "nearby floor edge visible"),
|
||||
(r"\bhanging outfits\b", "hanging fabric"),
|
||||
(r"\bclothing hooks\b", "wall hooks"),
|
||||
(r"\boutfit-check\b", "creator-shot"),
|
||||
(r"\boutfit framing\b", "body framing"),
|
||||
(r"\bfull outfits\b", "full bodies"),
|
||||
(r"\bcoordinated outfits\b", "coordinated posing"),
|
||||
)
|
||||
for pattern, replacement in replacements:
|
||||
text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bwith,\s*", "with ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r",\s*,", ",", text)
|
||||
return _clean_pair_punctuation(text)
|
||||
|
||||
|
||||
def softcore_outfit_sentence(label: str, outfit: str) -> str:
|
||||
outfit = str(outfit or "").strip()
|
||||
if not outfit:
|
||||
return ""
|
||||
lower = outfit.lower()
|
||||
if lower.startswith(("wears ", "wearing ", "in ")):
|
||||
return f"{label} {outfit}"
|
||||
return f"{label} wears {outfit}"
|
||||
|
||||
|
||||
def hardcore_clothing_sentence(label: str, clothing: str) -> str:
|
||||
clothing = str(clothing or "").strip().rstrip(".")
|
||||
if not clothing:
|
||||
return ""
|
||||
lower = clothing.lower()
|
||||
if lower.startswith(("fully nude", "nude")):
|
||||
return f"{label}'s body is fully exposed, bare skin unobstructed"
|
||||
if lower.startswith("partly nude"):
|
||||
return f"{label}'s body is partly exposed"
|
||||
if lower.startswith(("is ", "wears ", "wearing ", "keeps ", "has ", "with ")):
|
||||
return f"{label} {clothing}"
|
||||
return f"{label}'s clothing: {clothing}"
|
||||
|
||||
|
||||
def character_hardcore_clothing_entries(
|
||||
label_map: dict[str, dict[str, Any]],
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
pov_labels: list[str] | None,
|
||||
rng: Any,
|
||||
slot_hardcore_clothing: Callable[[dict[str, Any] | None, Any], str],
|
||||
) -> list[str]:
|
||||
pov_set = set(pov_labels or [])
|
||||
labels = [
|
||||
*[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))],
|
||||
*[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))],
|
||||
]
|
||||
entries: list[str] = []
|
||||
for label in labels:
|
||||
if label in pov_set:
|
||||
continue
|
||||
clothing = slot_hardcore_clothing(label_map.get(label), rng)
|
||||
sentence = hardcore_clothing_sentence(label, clothing)
|
||||
if sentence:
|
||||
entries.append(sentence)
|
||||
return entries
|
||||
|
||||
|
||||
def hardcore_row_access_flags(row: dict[str, Any]) -> dict[str, bool]:
|
||||
axis_values = row.get("item_axis_values")
|
||||
axis_text = item_axis_policy.context_text(axis_values=axis_values)
|
||||
role_text = " ".join(
|
||||
str(part or "")
|
||||
for part in (
|
||||
row.get("source_role_graph"),
|
||||
row.get("role_graph"),
|
||||
)
|
||||
).lower()
|
||||
detail_text = " ".join(
|
||||
str(part or "")
|
||||
for part in (
|
||||
row.get("item"),
|
||||
row.get("source_composition"),
|
||||
row.get("composition"),
|
||||
axis_text,
|
||||
)
|
||||
).lower()
|
||||
full_text = f"{role_text} {detail_text}"
|
||||
lower_access_text = f"{role_text} {axis_text}"
|
||||
return {
|
||||
"woman_lower": any(term in lower_access_text for term in WOMAN_LOWER_ACCESS_TERMS),
|
||||
"woman_upper": any(term in full_text for term in WOMAN_UPPER_ACCESS_TERMS),
|
||||
"man_lower": any(term in lower_access_text for term in MAN_LOWER_ACCESS_TERMS),
|
||||
}
|
||||
|
||||
|
||||
def _outfit_without_lower_body_blockers(outfit: str) -> str:
|
||||
text = str(outfit or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"\blingerie set\b", "lingerie top details", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bbrief set\b", "bra set", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bbodysuit with\b", "upper bodysuit detail with", text, flags=re.IGNORECASE)
|
||||
fragments = re.split(r"\s*,\s*|\s+\band\b\s+|\s+\bwith\b\s+|\s+\bunder\b\s+|\s+\bover\b\s+", text)
|
||||
kept = []
|
||||
for fragment in fragments:
|
||||
fragment = fragment.strip(" ,.;")
|
||||
fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE)
|
||||
if not fragment:
|
||||
continue
|
||||
lower = fragment.lower()
|
||||
if any(term in lower for term in LOWER_BODY_CLOTHING_TERMS):
|
||||
continue
|
||||
kept.append(fragment)
|
||||
if not kept:
|
||||
return ""
|
||||
deduped = []
|
||||
seen = set()
|
||||
for fragment in kept:
|
||||
key = re.sub(r"\W+", " ", fragment.lower()).strip()
|
||||
if key and key not in seen:
|
||||
deduped.append(fragment)
|
||||
seen.add(key)
|
||||
return ", ".join(deduped)
|
||||
|
||||
|
||||
def _outfit_without_upper_body_blockers(outfit: str) -> str:
|
||||
text = str(outfit or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"\blingerie set\b", "lingerie styling", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bbalconette bra and brief set\b", "briefs and garter styling", text, flags=re.IGNORECASE)
|
||||
fragments = re.split(r"\s*,\s*|\s+\band\s+|\s+\bwith\s+|\s+\bunder\s+|\s+\bover\s+", text)
|
||||
kept = []
|
||||
for fragment in fragments:
|
||||
fragment = fragment.strip(" ,.;")
|
||||
fragment = re.sub(r"^(?:and|with|under|over)\s+", "", fragment, flags=re.IGNORECASE)
|
||||
if not fragment:
|
||||
continue
|
||||
lower = fragment.lower()
|
||||
if any(term in lower for term in UPPER_BODY_CLOTHING_TERMS):
|
||||
continue
|
||||
kept.append(fragment)
|
||||
if not kept:
|
||||
return ""
|
||||
deduped = []
|
||||
seen = set()
|
||||
for fragment in kept:
|
||||
key = re.sub(r"\W+", " ", fragment.lower()).strip()
|
||||
if key and key not in seen:
|
||||
deduped.append(fragment)
|
||||
seen.add(key)
|
||||
return ", ".join(deduped)
|
||||
|
||||
|
||||
def hardcore_clothing_state(
|
||||
mode: str,
|
||||
softcore_outfit: str,
|
||||
continuity_map: dict[str, str],
|
||||
woman_access: str = "",
|
||||
) -> str:
|
||||
mode = mode if mode in continuity_map else "none"
|
||||
outfit = str(softcore_outfit or "").strip()
|
||||
if mode == "none" or not outfit:
|
||||
return ""
|
||||
base = continuity_map[mode]
|
||||
if mode == "explicit_nude":
|
||||
return f"Body exposure: {base}."
|
||||
if mode == "implied_nude":
|
||||
return f"Body exposure: {base}."
|
||||
if mode == "partially_removed" and woman_access == "lower":
|
||||
detail = _outfit_without_lower_body_blockers(outfit)
|
||||
base = "Woman A's lower body is clear; any lower garment is pulled aside or removed below the hips"
|
||||
if detail:
|
||||
return f"Clothing state: {base}; visible remaining styling: {detail}."
|
||||
return f"Clothing state: {base}."
|
||||
if mode == "partially_removed" and woman_access == "upper":
|
||||
detail = _outfit_without_upper_body_blockers(outfit)
|
||||
base = "Woman A's breasts and upper body are clear; any bra cup, bodice, or top panel is pulled aside or removed"
|
||||
if detail:
|
||||
return f"Clothing state: {base}; visible remaining styling: {detail}."
|
||||
return f"Clothing state: {base}."
|
||||
if mode == "partially_removed":
|
||||
return f"Clothing state: Woman A keeps the outfit mostly on; teaser outfit detail: {outfit}."
|
||||
return f"Clothing state: {base}; teaser outfit detail: {outfit}."
|
||||
|
||||
|
||||
def default_man_hardcore_clothing_entries(
|
||||
men_count: int,
|
||||
pov_labels: list[str] | None,
|
||||
configured_entries: list[str],
|
||||
rng: Any,
|
||||
needs_lower_access: bool,
|
||||
choose: Callable[[Any, list[str]], str],
|
||||
) -> list[str]:
|
||||
pov_set = set(pov_labels or [])
|
||||
configured_labels = {
|
||||
match.group(1)
|
||||
for entry in configured_entries
|
||||
for match in [re.match(r"^\s*(Man [A-Z])\b", str(entry or ""))]
|
||||
if match
|
||||
}
|
||||
pool = INSTA_OF_HARDCORE_MEN_CLOTHING_LOWER_ACCESS if needs_lower_access else INSTA_OF_HARDCORE_MEN_CLOTHING_VISIBLE
|
||||
entries = []
|
||||
for index in range(max(0, int(men_count))):
|
||||
label = f"Man {chr(ord('A') + index)}"
|
||||
if label in pov_set or label in configured_labels:
|
||||
continue
|
||||
entries.append(hardcore_clothing_sentence(label, choose(rng, pool)))
|
||||
return entries
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardcorePairClothingRoute:
|
||||
access_flags: dict[str, bool]
|
||||
woman_access: str
|
||||
default_man_hardcore_clothing: list[str]
|
||||
hardcore_clothing_state: str
|
||||
hardcore_clothing_sentence: str
|
||||
requires_body_exposure_scene: bool
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"access_flags": dict(self.access_flags),
|
||||
"woman_access": self.woman_access,
|
||||
"default_man_hardcore_clothing": list(self.default_man_hardcore_clothing),
|
||||
"hardcore_clothing_state": self.hardcore_clothing_state,
|
||||
"hardcore_clothing_sentence": self.hardcore_clothing_sentence,
|
||||
"requires_body_exposure_scene": self.requires_body_exposure_scene,
|
||||
}
|
||||
|
||||
|
||||
def resolve_hardcore_pair_clothing_result(
|
||||
*,
|
||||
hard_row: dict[str, Any],
|
||||
mode: str,
|
||||
softcore_outfit: str,
|
||||
character_hardcore_clothing_entries: list[str],
|
||||
men_count: int,
|
||||
pov_labels: list[str] | None,
|
||||
rng: Any,
|
||||
continuity_map: dict[str, str],
|
||||
choose: Callable[[Any, list[str]], str],
|
||||
) -> HardcorePairClothingRoute:
|
||||
access_flags = hardcore_row_access_flags(hard_row)
|
||||
woman_access = "lower" if access_flags["woman_lower"] else "upper" if access_flags["woman_upper"] else ""
|
||||
default_man_entries = default_man_hardcore_clothing_entries(
|
||||
men_count,
|
||||
pov_labels,
|
||||
character_hardcore_clothing_entries,
|
||||
rng,
|
||||
access_flags["man_lower"],
|
||||
choose,
|
||||
)
|
||||
has_primary_hardcore_clothing = any(entry.startswith("Woman A") for entry in character_hardcore_clothing_entries)
|
||||
fallback_state = "" if has_primary_hardcore_clothing else hardcore_clothing_state(
|
||||
mode,
|
||||
softcore_outfit,
|
||||
continuity_map,
|
||||
woman_access=woman_access,
|
||||
)
|
||||
hard_clothing_parts = [
|
||||
part.strip().rstrip(".")
|
||||
for part in (
|
||||
fallback_state,
|
||||
*character_hardcore_clothing_entries,
|
||||
*default_man_entries,
|
||||
)
|
||||
if str(part or "").strip()
|
||||
]
|
||||
hard_clothing_state = "; ".join(hard_clothing_parts)
|
||||
scene_cleanup_terms = (
|
||||
"body is fully exposed",
|
||||
"bare skin unobstructed",
|
||||
"body is partly exposed",
|
||||
"lower body is clear",
|
||||
"upper body are clear",
|
||||
"pulled aside",
|
||||
"removed below the hips",
|
||||
"pants and underwear are pulled down",
|
||||
)
|
||||
hard_clothing_lower = hard_clothing_state.lower()
|
||||
return HardcorePairClothingRoute(
|
||||
access_flags=access_flags,
|
||||
woman_access=woman_access,
|
||||
default_man_hardcore_clothing=default_man_entries,
|
||||
hardcore_clothing_state=hard_clothing_state,
|
||||
hardcore_clothing_sentence=f"{hard_clothing_state}. " if hard_clothing_state else "",
|
||||
requires_body_exposure_scene=(
|
||||
any(access_flags.values())
|
||||
or any(term in hard_clothing_lower for term in scene_cleanup_terms)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_hardcore_pair_clothing(
|
||||
*,
|
||||
hard_row: dict[str, Any],
|
||||
mode: str,
|
||||
softcore_outfit: str,
|
||||
character_hardcore_clothing_entries: list[str],
|
||||
men_count: int,
|
||||
pov_labels: list[str] | None,
|
||||
rng: Any,
|
||||
continuity_map: dict[str, str],
|
||||
choose: Callable[[Any, list[str]], str],
|
||||
) -> dict[str, Any]:
|
||||
return resolve_hardcore_pair_clothing_result(
|
||||
hard_row=hard_row,
|
||||
mode=mode,
|
||||
softcore_outfit=softcore_outfit,
|
||||
character_hardcore_clothing_entries=character_hardcore_clothing_entries,
|
||||
men_count=men_count,
|
||||
pov_labels=pov_labels,
|
||||
rng=rng,
|
||||
continuity_map=continuity_map,
|
||||
choose=choose,
|
||||
).as_dict()
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import category_library as category_policy
|
||||
|
||||
|
||||
INSTA_OF_SOFT_LEVELS = {
|
||||
"social_tease": "Instagram-style thirst-trap post, suggestive polished social feed energy",
|
||||
"lingerie_tease": "premium OF teaser set, lingerie-focused, sensual and intimate",
|
||||
"implied_nude": "implied nude creator set, strategically covered body and intimate teaser framing",
|
||||
"explicit_tease": "stronger adult teaser set with bolder nude-adjacent styling and solo-tease framing",
|
||||
"explicit_nude": "explicit nude creator set with fully nude solo-tease framing",
|
||||
}
|
||||
|
||||
INSTA_OF_HARDCORE_LEVELS = {
|
||||
"explicit": "explicit adult creator content with clear sexual contact and adult-only framing",
|
||||
"hardcore": "hardcore adult creator content with anatomically clear sexual contact and intense body language",
|
||||
}
|
||||
|
||||
INSTA_OF_PLATFORM_STYLES = {
|
||||
"hybrid": "hybrid Instagram-to-OF creator shoot, polished social-media framing with intimate subscriber-content energy",
|
||||
"instagram": "Instagram-inspired creator shoot, polished mirror-selfie and feed-post aesthetics",
|
||||
"onlyfans": "OnlyFans-inspired creator shoot, intimate subscriber-view camera and candid premium-content framing",
|
||||
}
|
||||
|
||||
INSTA_OF_HARDCORE_CLOTHING_CONTINUITY = {
|
||||
"none": "",
|
||||
"same_outfit": "Woman A keeps her teaser outfit on with the body contact readable",
|
||||
"partially_removed": "Woman A's teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed",
|
||||
"implied_nude": "Woman A's body is partly exposed, with fabric slipping off or covering only part of the body",
|
||||
"explicit_nude": "Woman A's body is fully exposed, bare skin unobstructed",
|
||||
}
|
||||
|
||||
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
|
||||
|
||||
HARDCORE_DETAIL_DIRECTIVES = {
|
||||
"compact": "Use one compact position-first sexual action sentence; avoid repeated aftermath wording. ",
|
||||
"balanced": "",
|
||||
"dense": "Use dense but coherent motion, contact, and aftermath detail while keeping one readable body position. ",
|
||||
}
|
||||
|
||||
INSTA_OF_NEGATIVE = (
|
||||
"minors, childlike appearance, teen, underage, schoolgirl, non-consensual, coercion, rape, "
|
||||
"violence, injury, blood, gore, incest, bestiality, watermark, logo, readable username, social media UI"
|
||||
)
|
||||
|
||||
INSTA_OF_SOFT_NEGATIVE = (
|
||||
INSTA_OF_NEGATIVE
|
||||
+ ", explicit intercourse, penetration, oral sex, cumshot, genital contact, group sex, "
|
||||
"shirtless partner, bare-chested partner, partner nudity"
|
||||
)
|
||||
|
||||
INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL = {
|
||||
"social_tease": "Casual clothes / Smart casual",
|
||||
"lingerie_tease": "Provocative erotic clothes / Provocative lingerie",
|
||||
"implied_nude": "Provocative erotic clothes / Provocative lingerie",
|
||||
"explicit_tease": "Provocative erotic clothes / Sheer exposed",
|
||||
"explicit_nude": "Provocative erotic clothes / Nude accessories",
|
||||
}
|
||||
|
||||
INSTA_OF_SOFTCORE_OUTFITS = {
|
||||
"social_tease": [
|
||||
"cropped fitted tee, low-rise jeans, delicate jewelry, and polished feed-post styling",
|
||||
"oversized off-shoulder sweater with fitted shorts and soft lounge socks",
|
||||
"ribbed tank top, mini skirt, hoop earrings, and casual creator styling",
|
||||
"silky camisole tucked into relaxed trousers with a subtle waist chain",
|
||||
"sporty crop top, bike shorts, clean sneakers, and glossy social-feed styling",
|
||||
"button-down shirt tied at the waist over a fitted bralette and denim shorts",
|
||||
"body-hugging knit dress with bare shoulders and simple heels",
|
||||
"relaxed hoodie half-zipped over a crop top with high-cut shorts",
|
||||
],
|
||||
"lingerie_tease": [
|
||||
"black lace lingerie set with opaque cups, high-waisted briefs, garter straps, and sheer robe",
|
||||
"satin bralette and matching high-waisted panties under an oversized shirt",
|
||||
"lace bodysuit with opaque cups, soft stockings, and delicate garter details",
|
||||
"silk slip dress with thin straps, thigh slit, and subtle lace trim",
|
||||
"matching balconette bra and brief set under a loosely draped satin robe",
|
||||
"velvet lingerie set with covered cups, garter belt, sheer stockings, and small gold accents",
|
||||
"mesh robe over a covered lace teddy, styled as a premium creator teaser",
|
||||
"structured corset top with opaque panels, matching briefs, and sheer stockings",
|
||||
],
|
||||
"implied_nude": [
|
||||
"oversized white shirt slipping off one shoulder, body mostly covered, bare legs, and soft creator-shot styling",
|
||||
"towel wrap held across the chest and hips, implied nude but fully covered",
|
||||
"satin sheet wrapped around the body with shoulders and legs visible but intimate areas covered",
|
||||
"open robe held closed by hand, implied nude beneath without explicit exposure",
|
||||
"bath towel and damp hair after a shower, covered chest and hips, intimate creator styling",
|
||||
"soft blanket wrapped around the body, bare shoulders visible, sensual but covered",
|
||||
],
|
||||
"explicit_tease": [
|
||||
"sheer robe over matching lingerie with intimate areas obscured by lace pattern and pose",
|
||||
"wet-look bodysuit with opaque panels, high-cut legs, and glossy club-light styling",
|
||||
"transparent mesh dress over covered lingerie, posed as an adult creator teaser",
|
||||
"lace teddy with strategic opaque embroidery, garter straps, and sheer stockings",
|
||||
"bare-shoulder robe opened around covered lingerie, bold solo adult tease",
|
||||
"strappy lingerie set with covered cups and high-waisted bottoms, styled as a stronger solo teaser",
|
||||
],
|
||||
"explicit_nude": [
|
||||
"body fully exposed with jewelry accents and direct adult selfie confidence",
|
||||
"mirror-selfie body exposure with jewelry accents and bold creator-shot framing",
|
||||
"body fully exposed with direct eye contact and soft creator-shot styling",
|
||||
"vanity-mirror body exposure with necklace detail and premium creator-shot styling",
|
||||
"shower-afterglow body exposure with wet hair, skin highlights, and phone-shot framing",
|
||||
"indoor body exposure with one hand holding the phone and direct camera awareness",
|
||||
],
|
||||
}
|
||||
|
||||
INSTA_OF_SOFTCORE_POSES = {
|
||||
"social_tease": [
|
||||
"taking a mirror selfie with one hip angled and relaxed social-feed confidence",
|
||||
"leaning against a doorway with one hand holding the phone and a casual teasing smile",
|
||||
"sitting casually for a polished outfit-check selfie",
|
||||
"standing by the window with shoulders relaxed and body angled toward the phone",
|
||||
"posing in a clean feed-post stance with one hand at the waist",
|
||||
"stretching one arm above the head in a casual morning selfie pose",
|
||||
],
|
||||
"lingerie_tease": [
|
||||
"taking a mirror lingerie selfie with one hip angled and the outfit clearly visible",
|
||||
"kneeling in a covered lingerie teaser pose with hands resting on fabric",
|
||||
"leaning with the robe draped around covered lingerie",
|
||||
"standing in a three-quarter lingerie outfit-check pose with legs softly crossed",
|
||||
"sitting with stockings and garter details visible in a controlled teaser pose",
|
||||
"turning slightly over one shoulder to show the lingerie silhouette",
|
||||
],
|
||||
"implied_nude": [
|
||||
"holding the towel or sheet securely in place while posing for an implied nude selfie",
|
||||
"sitting with soft fabric wrapped securely around the body and shoulders visible",
|
||||
"standing by a mirror with a towel wrapped around the body",
|
||||
"reclining under satin fabric with intimate areas fully obscured",
|
||||
"holding an open robe closed in a covered implied nude teaser pose",
|
||||
"looking into the phone camera while wrapped in a blanket with bare shoulders visible",
|
||||
],
|
||||
"explicit_tease": [
|
||||
"posing in a stronger adult teaser stance with covered lingerie and direct camera awareness",
|
||||
"kneeling with a sheer robe arranged around covered lingerie",
|
||||
"standing close to the mirror with the outfit framed boldly",
|
||||
"leaning forward slightly with hands on the robe and intimate areas obscured",
|
||||
"sitting in a bolder covered lingerie pose with direct eye contact",
|
||||
"arching subtly in a solo adult tease while the styling keeps explicit anatomy obscured",
|
||||
],
|
||||
"explicit_nude": [
|
||||
"taking a bold mirror selfie with direct eye contact and the body clearly framed",
|
||||
"posing with body fully exposed and jewelry accents as styling",
|
||||
"standing with body fully exposed in a premium creator-shot pose",
|
||||
"reclining with body fully exposed and the phone held close",
|
||||
"turning slightly in a mirror pose with the body framed head-to-thigh",
|
||||
"kneeling in a controlled adult teaser pose with body fully exposed and direct phone-camera awareness",
|
||||
],
|
||||
}
|
||||
|
||||
INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS = [
|
||||
"satin slip dress under an oversized shirt",
|
||||
"soft cardigan over a camisole with relaxed trousers",
|
||||
"fitted crop top with high-waisted jeans",
|
||||
"silky robe over a covered bralette and lounge shorts",
|
||||
"bodycon mini dress with simple heels",
|
||||
"ribbed tank top with joggers and delicate jewelry",
|
||||
"oversized tee with fitted shorts and lounge socks",
|
||||
"button-down shirt with a fitted skirt",
|
||||
]
|
||||
|
||||
INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS = [
|
||||
"fitted black tee with dark jeans",
|
||||
"buttoned linen shirt with chinos",
|
||||
"hoodie and joggers",
|
||||
"open overshirt over a fitted tank with relaxed trousers",
|
||||
"gym tee with track pants and a towel over one shoulder",
|
||||
"casual knit shirt with tailored trousers",
|
||||
"dark crewneck sweater with jeans",
|
||||
"short-sleeve button-up shirt with relaxed shorts",
|
||||
]
|
||||
|
||||
SOFTCORE_CAST_POSES = [
|
||||
"standing together for a mirror selfie with relaxed close body language",
|
||||
"posing shoulder-to-shoulder in a creator-shot group teaser",
|
||||
"leaning together in a polished subscriber preview",
|
||||
"sitting close together with relaxed hands and styled outfit visibility",
|
||||
"arranged around Woman A in a flirtatious creator-teaser pose",
|
||||
"posing together as a coordinated adult creator set",
|
||||
"standing near the phone tripod with relaxed teasing body language",
|
||||
"framed together in a softcore cast reveal",
|
||||
]
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return not value
|
||||
text = str(value).strip().lower()
|
||||
return text in {"false", "0", "no", "off", "disabled"}
|
||||
|
||||
|
||||
def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
number = default
|
||||
return max(min_value, min(max_value, number))
|
||||
|
||||
|
||||
def _normalize_free_text_values(values: Any) -> list[str]:
|
||||
if isinstance(values, str):
|
||||
raw_values = [part.strip() for part in re.split(r"[\n;]+", values) if part.strip()]
|
||||
elif isinstance(values, (list, tuple, set)):
|
||||
raw_values = list(values)
|
||||
else:
|
||||
raw_values = []
|
||||
normalized: list[str] = []
|
||||
for raw_value in raw_values:
|
||||
value = str(raw_value or "").strip()
|
||||
if value and value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def character_softcore_outfit_values(source: str, custom_outfits: str = "") -> list[str]:
|
||||
source = str(source or "no_change").strip()
|
||||
if source in INSTA_OF_SOFTCORE_OUTFITS:
|
||||
return list(INSTA_OF_SOFTCORE_OUTFITS[source])
|
||||
if source == "partner_woman":
|
||||
return list(INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS)
|
||||
if source == "partner_man":
|
||||
return list(INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS)
|
||||
if source == "custom":
|
||||
return _normalize_free_text_values(custom_outfits)
|
||||
return []
|
||||
|
||||
|
||||
def hardcore_detail_density_choices() -> list[str]:
|
||||
return list(HARDCORE_DETAIL_DENSITY_CHOICES)
|
||||
|
||||
|
||||
def hardcore_detail_directive(density: Any) -> str:
|
||||
return HARDCORE_DETAIL_DIRECTIVES.get(str(density or "balanced"), "")
|
||||
|
||||
|
||||
def character_hardcore_clothing_values(state: str, custom_clothing: str = "") -> list[str]:
|
||||
state = str(state or "no_change").strip()
|
||||
if state == "fully_nude":
|
||||
return ["fully nude"]
|
||||
if state == "partly_exposed":
|
||||
return ["partly nude, body exposed"]
|
||||
if state == "same_outfit":
|
||||
return ["keeps the teaser outfit on with the body contact readable"]
|
||||
if state == "partially_removed":
|
||||
return ["teaser outfit is pushed aside and partly removed where needed, leaving body contact unobstructed"]
|
||||
if state == "custom":
|
||||
return _normalize_free_text_values(custom_clothing)
|
||||
return []
|
||||
|
||||
|
||||
def build_insta_of_options_json(
|
||||
softcore_cast: str = "solo",
|
||||
hardcore_cast: str = "use_counts",
|
||||
hardcore_women_count: int = 1,
|
||||
hardcore_men_count: int = 1,
|
||||
softcore_level: str = "lingerie_tease",
|
||||
hardcore_level: str = "hardcore",
|
||||
platform_style: str = "hybrid",
|
||||
continuity: str = "same_creator_same_room",
|
||||
hardcore_clothing_continuity: str = "partially_removed",
|
||||
softcore_camera_mode: str = "handheld_selfie",
|
||||
hardcore_camera_mode: str = "from_camera_config",
|
||||
camera_detail: str = "from_camera_config",
|
||||
softcore_expression_intensity: float = 0.45,
|
||||
hardcore_expression_intensity: float = 0.85,
|
||||
softcore_expression_enabled: bool = True,
|
||||
hardcore_expression_enabled: bool = True,
|
||||
hardcore_detail_density: str = "balanced",
|
||||
hardcore_detail_density_choices: list[str] | tuple[str, ...] = tuple(HARDCORE_DETAIL_DENSITY_CHOICES),
|
||||
) -> str:
|
||||
hardcore_detail_density = (
|
||||
hardcore_detail_density if hardcore_detail_density in hardcore_detail_density_choices else "balanced"
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"softcore_cast": softcore_cast,
|
||||
"hardcore_cast": hardcore_cast,
|
||||
"hardcore_women_count": int(hardcore_women_count),
|
||||
"hardcore_men_count": int(hardcore_men_count),
|
||||
"softcore_level": softcore_level,
|
||||
"hardcore_level": hardcore_level,
|
||||
"platform_style": platform_style,
|
||||
"continuity": continuity,
|
||||
"hardcore_clothing_continuity": hardcore_clothing_continuity,
|
||||
"softcore_camera_mode": softcore_camera_mode,
|
||||
"hardcore_camera_mode": hardcore_camera_mode,
|
||||
"camera_detail": camera_detail,
|
||||
"softcore_expression_enabled": not _is_false(softcore_expression_enabled),
|
||||
"hardcore_expression_enabled": not _is_false(hardcore_expression_enabled),
|
||||
"softcore_expression_intensity": _clamped_float(softcore_expression_intensity, 0.45),
|
||||
"hardcore_expression_intensity": _clamped_float(hardcore_expression_intensity, 0.85),
|
||||
"hardcore_detail_density": hardcore_detail_density,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def parse_insta_of_options(
|
||||
options_json: str | dict[str, Any] | None,
|
||||
*,
|
||||
camera_mode_choices: dict[str, str] | list[str] | tuple[str, ...],
|
||||
camera_detail_choices: list[str] | tuple[str, ...],
|
||||
hardcore_detail_density_choices: list[str] | tuple[str, ...] = tuple(HARDCORE_DETAIL_DENSITY_CHOICES),
|
||||
) -> dict[str, Any]:
|
||||
defaults = {
|
||||
"softcore_cast": "solo",
|
||||
"hardcore_cast": "use_counts",
|
||||
"hardcore_women_count": 1,
|
||||
"hardcore_men_count": 1,
|
||||
"softcore_level": "lingerie_tease",
|
||||
"hardcore_level": "hardcore",
|
||||
"platform_style": "hybrid",
|
||||
"continuity": "same_creator_same_room",
|
||||
"hardcore_clothing_continuity": "partially_removed",
|
||||
"softcore_camera_mode": "handheld_selfie",
|
||||
"hardcore_camera_mode": "from_camera_config",
|
||||
"camera_detail": "from_camera_config",
|
||||
"softcore_expression_enabled": True,
|
||||
"hardcore_expression_enabled": True,
|
||||
"softcore_expression_intensity": 0.45,
|
||||
"hardcore_expression_intensity": 0.85,
|
||||
"hardcore_detail_density": "balanced",
|
||||
}
|
||||
if not options_json:
|
||||
return defaults
|
||||
if isinstance(options_json, dict):
|
||||
raw = options_json
|
||||
else:
|
||||
try:
|
||||
raw = json.loads(str(options_json))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid Insta/OF options JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Insta/OF options must be a JSON object")
|
||||
|
||||
valid_camera_modes = set(camera_mode_choices) if isinstance(camera_mode_choices, dict) else set(camera_mode_choices)
|
||||
parsed = {**defaults, **raw}
|
||||
parsed["softcore_cast"] = parsed["softcore_cast"] if parsed["softcore_cast"] in ("solo", "same_as_hardcore") else defaults["softcore_cast"]
|
||||
parsed["hardcore_cast"] = parsed["hardcore_cast"] if parsed["hardcore_cast"] in ("use_counts", "couple", "threesome", "group") else defaults["hardcore_cast"]
|
||||
parsed["softcore_level"] = parsed["softcore_level"] if parsed["softcore_level"] in INSTA_OF_SOFT_LEVELS else defaults["softcore_level"]
|
||||
parsed["hardcore_level"] = parsed["hardcore_level"] if parsed["hardcore_level"] in INSTA_OF_HARDCORE_LEVELS else defaults["hardcore_level"]
|
||||
parsed["platform_style"] = parsed["platform_style"] if parsed["platform_style"] in INSTA_OF_PLATFORM_STYLES else defaults["platform_style"]
|
||||
parsed["continuity"] = parsed["continuity"] if parsed["continuity"] in ("same_creator_same_room", "same_creator_new_scene") else defaults["continuity"]
|
||||
parsed["hardcore_clothing_continuity"] = (
|
||||
parsed["hardcore_clothing_continuity"]
|
||||
if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY
|
||||
else defaults["hardcore_clothing_continuity"]
|
||||
)
|
||||
parsed["softcore_camera_mode"] = (
|
||||
parsed["softcore_camera_mode"]
|
||||
if parsed["softcore_camera_mode"] in valid_camera_modes or parsed["softcore_camera_mode"] == "from_camera_config"
|
||||
else defaults["softcore_camera_mode"]
|
||||
)
|
||||
if (
|
||||
parsed["hardcore_camera_mode"] not in valid_camera_modes
|
||||
and parsed["hardcore_camera_mode"] not in ("from_camera_config", "same_as_softcore")
|
||||
):
|
||||
parsed["hardcore_camera_mode"] = defaults["hardcore_camera_mode"]
|
||||
parsed["camera_detail"] = (
|
||||
parsed["camera_detail"]
|
||||
if parsed["camera_detail"] in camera_detail_choices or parsed["camera_detail"] == "from_camera_config"
|
||||
else defaults["camera_detail"]
|
||||
)
|
||||
parsed["softcore_expression_enabled"] = not _is_false(parsed.get("softcore_expression_enabled", True))
|
||||
parsed["hardcore_expression_enabled"] = not _is_false(parsed.get("hardcore_expression_enabled", True))
|
||||
parsed["softcore_expression_intensity"] = _clamped_float(
|
||||
parsed.get("softcore_expression_intensity"),
|
||||
defaults["softcore_expression_intensity"],
|
||||
)
|
||||
parsed["hardcore_expression_intensity"] = _clamped_float(
|
||||
parsed.get("hardcore_expression_intensity"),
|
||||
defaults["hardcore_expression_intensity"],
|
||||
)
|
||||
parsed["hardcore_detail_density"] = (
|
||||
parsed["hardcore_detail_density"]
|
||||
if parsed.get("hardcore_detail_density") in hardcore_detail_density_choices
|
||||
else defaults["hardcore_detail_density"]
|
||||
)
|
||||
for key in ("hardcore_women_count", "hardcore_men_count"):
|
||||
try:
|
||||
parsed[key] = max(0, min(12, int(parsed[key])))
|
||||
except (TypeError, ValueError):
|
||||
parsed[key] = defaults[key]
|
||||
return parsed
|
||||
|
||||
|
||||
def hardcore_counts(options: dict[str, Any]) -> tuple[int, int]:
|
||||
policy = str(options.get("hardcore_cast", "use_counts"))
|
||||
if policy == "couple":
|
||||
women_count, men_count = 1, 1
|
||||
elif policy == "threesome":
|
||||
women_count, men_count = 2, 1
|
||||
elif policy == "group":
|
||||
women_count, men_count = 3, 2
|
||||
else:
|
||||
women_count = int(options.get("hardcore_women_count") or 0)
|
||||
men_count = int(options.get("hardcore_men_count") or 0)
|
||||
women_count = max(1, min(12, women_count))
|
||||
men_count = max(0, min(12, men_count))
|
||||
if women_count + men_count < 2:
|
||||
men_count = 1
|
||||
return women_count, men_count
|
||||
|
||||
|
||||
def softcore_category(level: str) -> tuple[str, str]:
|
||||
subcategory = INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL.get(
|
||||
level,
|
||||
INSTA_OF_SOFTCORE_SUBCATEGORY_BY_LEVEL["lingerie_tease"],
|
||||
)
|
||||
exact_choice = category_policy.split_exact_subcategory_choice(
|
||||
category_policy.load_category_library(),
|
||||
subcategory,
|
||||
)
|
||||
category = exact_choice[0]["name"] if exact_choice else subcategory.split(" / ", 1)[0]
|
||||
return category, subcategory
|
||||
|
||||
|
||||
def softcore_outfit_pool(level: str) -> list[str]:
|
||||
return list(INSTA_OF_SOFTCORE_OUTFITS.get(level, INSTA_OF_SOFTCORE_OUTFITS["lingerie_tease"]))
|
||||
|
||||
|
||||
def softcore_pose_pool(level: str) -> list[str]:
|
||||
return list(INSTA_OF_SOFTCORE_POSES.get(level, INSTA_OF_SOFTCORE_POSES["lingerie_tease"]))
|
||||
|
||||
|
||||
def softcore_item_prompt_label(level: str) -> str:
|
||||
return "Body exposure" if level == "explicit_nude" else "Outfit"
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import row_normalization as row_policy
|
||||
from . import softcore_text_policy
|
||||
except ImportError: # Allows local smoke tests with `python tools/prompt_smoke.py`.
|
||||
import row_normalization as row_policy
|
||||
import softcore_text_policy
|
||||
|
||||
|
||||
def _labeled_expression_sentence(label: str, expression: Any) -> str:
|
||||
expression = str(expression or "").strip()
|
||||
if not expression:
|
||||
return ""
|
||||
return f"{label}: {expression}. "
|
||||
|
||||
|
||||
def _prepend_trigger(prompt: str, trigger: str, enabled: bool) -> str:
|
||||
return row_policy.prepend_trigger(prompt, trigger, enabled)
|
||||
|
||||
|
||||
def _combined_negative(base: str, extra: str) -> str:
|
||||
return row_policy.combined_negative(base, extra)
|
||||
|
||||
|
||||
def assemble_insta_pair_metadata(
|
||||
*,
|
||||
active_trigger: str,
|
||||
prepend_trigger_to_prompt: bool,
|
||||
extra_positive: str,
|
||||
extra_negative: str,
|
||||
soft_negative_base: str,
|
||||
hard_negative_base: str,
|
||||
options: dict[str, Any],
|
||||
platform_style: str,
|
||||
soft_descriptor_sentence: str,
|
||||
soft_level: str,
|
||||
soft_cast: str,
|
||||
soft_cast_presence: str,
|
||||
soft_cast_styling_sentence: str,
|
||||
soft_row: dict[str, Any],
|
||||
soft_camera_scene_sentence: str,
|
||||
soft_camera_sentence: str,
|
||||
hard_level: str,
|
||||
hard_cast: str,
|
||||
cast_descriptor_text: str,
|
||||
pov_directive: str,
|
||||
pov_character_labels: list[str],
|
||||
hard_clothing_sentence: str,
|
||||
hard_row: dict[str, Any],
|
||||
hard_scene: str,
|
||||
hard_camera_scene_sentence: str,
|
||||
hard_composition: str,
|
||||
hard_detail_directive: str,
|
||||
hard_camera_sentence: str,
|
||||
descriptor: str,
|
||||
soft_partner_outfit_text: str,
|
||||
soft_partner_styling: dict[str, Any],
|
||||
soft_camera_scene_directive: str,
|
||||
soft_camera_config: dict[str, Any],
|
||||
soft_camera_directive: str,
|
||||
hard_camera_scene_directive: str,
|
||||
hard_camera_config: dict[str, Any],
|
||||
hard_camera_directive: str,
|
||||
camera_caption_text: Callable[[dict[str, Any]], str],
|
||||
cast_descriptors: list[str],
|
||||
character_hardcore_clothing_entries: list[str],
|
||||
default_man_hardcore_clothing_entries: list[str],
|
||||
hard_clothing_state: str,
|
||||
hard_detail_density: str,
|
||||
hard_women_count: int,
|
||||
hard_men_count: int,
|
||||
character_slots: list[dict[str, Any]],
|
||||
character_slot_map: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
soft_prompt = (
|
||||
f"Insta/OF softcore mode: {platform_style}. "
|
||||
f"{soft_descriptor_sentence}"
|
||||
f"Softcore setup: {soft_level}. Cast: {soft_cast}. "
|
||||
f"{soft_cast_presence}"
|
||||
f"{soft_cast_styling_sentence}"
|
||||
f"{soft_row['softcore_item_prompt_label']}: {soft_row['item']}. Pose: {soft_row['pose']}. Setting: {soft_row['scene_text']}. "
|
||||
f"{soft_camera_scene_sentence}"
|
||||
f"{_labeled_expression_sentence('Facial expression', soft_row.get('expression'))}"
|
||||
f"Composition: {soft_row['composition']}. "
|
||||
f"{soft_camera_sentence}"
|
||||
f"{softcore_text_policy.softcore_style_directive()} "
|
||||
f"{soft_row['positive_suffix']}."
|
||||
)
|
||||
hard_prompt = (
|
||||
f"Insta/OF hardcore mode: {platform_style}. "
|
||||
f"Hardcore setup: {hard_level}. Cast: {hard_cast}. "
|
||||
f"Cast descriptors: {cast_descriptor_text}. "
|
||||
f"{pov_directive + ' ' if pov_directive else ''}"
|
||||
f"{'Keep Woman A visually central from the POV camera. ' if pov_character_labels else 'Keep Woman A visually central. '}"
|
||||
f"{hard_clothing_sentence}"
|
||||
f"Role graph: {hard_row['role_graph']} Sexual scene: {hard_row['item']}. "
|
||||
f"Setting: {hard_scene}. "
|
||||
f"{hard_camera_scene_sentence}"
|
||||
f"{_labeled_expression_sentence('Facial expressions', hard_row.get('expression'))}"
|
||||
f"Composition: {hard_composition}. "
|
||||
f"{hard_detail_directive}"
|
||||
f"{hard_camera_sentence}"
|
||||
f"{hard_row['positive_suffix']}."
|
||||
)
|
||||
soft_caption_parts = [
|
||||
active_trigger,
|
||||
"Insta/OF softcore mode",
|
||||
descriptor,
|
||||
soft_level,
|
||||
soft_row["item"],
|
||||
soft_row["pose"],
|
||||
soft_partner_outfit_text,
|
||||
soft_partner_styling["pose"],
|
||||
soft_row["scene_text"],
|
||||
soft_camera_scene_directive,
|
||||
soft_row["composition"],
|
||||
camera_caption_text(soft_camera_config) if soft_camera_directive else "",
|
||||
]
|
||||
hard_caption_parts = [
|
||||
active_trigger,
|
||||
"Insta/OF hardcore mode",
|
||||
"Woman A",
|
||||
descriptor,
|
||||
hard_cast,
|
||||
hard_row["role_graph"],
|
||||
hard_row["item"],
|
||||
hard_scene,
|
||||
hard_camera_scene_directive,
|
||||
hard_composition,
|
||||
camera_caption_text(hard_camera_config) if hard_camera_directive else "",
|
||||
]
|
||||
normalized_text = row_policy.normalize_pair_text_outputs(
|
||||
active_trigger=active_trigger,
|
||||
prepend_trigger_to_prompt=bool(prepend_trigger_to_prompt),
|
||||
extra_positive=extra_positive,
|
||||
extra_negative=extra_negative,
|
||||
soft_prompt=soft_prompt,
|
||||
hard_prompt=hard_prompt,
|
||||
soft_negative_base=soft_negative_base,
|
||||
hard_negative_base=hard_negative_base,
|
||||
soft_caption_parts=soft_caption_parts,
|
||||
hard_caption_parts=hard_caption_parts,
|
||||
)
|
||||
|
||||
pair = {
|
||||
"mode": "Insta/OF",
|
||||
"options": options,
|
||||
"shared_descriptor": descriptor,
|
||||
"shared_cast_descriptors": cast_descriptors,
|
||||
"pov_character_labels": pov_character_labels,
|
||||
"pov_prompt_directive": pov_directive,
|
||||
"softcore_partner_styling": soft_partner_styling,
|
||||
"character_hardcore_clothing": character_hardcore_clothing_entries,
|
||||
"default_man_hardcore_clothing": default_man_hardcore_clothing_entries,
|
||||
"hardcore_clothing_state": hard_clothing_state,
|
||||
"hardcore_detail_density": hard_detail_density,
|
||||
"hardcore_position_config": hard_row.get("hardcore_position_config", {}),
|
||||
"softcore_prompt": normalized_text["soft_prompt"],
|
||||
"hardcore_prompt": normalized_text["hard_prompt"],
|
||||
"softcore_negative_prompt": normalized_text["soft_negative"],
|
||||
"hardcore_negative_prompt": normalized_text["hard_negative"],
|
||||
"softcore_caption": normalized_text["soft_caption"],
|
||||
"hardcore_caption": normalized_text["hard_caption"],
|
||||
"softcore_row": soft_row,
|
||||
"hardcore_row": hard_row,
|
||||
"hardcore_women_count": hard_women_count,
|
||||
"hardcore_men_count": hard_men_count,
|
||||
"character_cast_slots": character_slots,
|
||||
"character_slot_labels": sorted(character_slot_map),
|
||||
"softcore_camera_config": soft_camera_config,
|
||||
"hardcore_camera_config": hard_camera_config,
|
||||
"softcore_camera_directive": soft_camera_directive,
|
||||
"hardcore_camera_directive": hard_camera_directive,
|
||||
"softcore_camera_scene_directive": soft_camera_scene_directive,
|
||||
"hardcore_camera_scene_directive": hard_camera_scene_directive,
|
||||
}
|
||||
return row_policy.normalize_pair_metadata(pair, active_trigger=active_trigger)
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import pair_clothing
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import pair_clothing
|
||||
|
||||
|
||||
BuildPrompt = Callable[..., dict[str, Any]]
|
||||
AxisRng = Callable[[dict[str, int], str, int, int], Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstaPairRowsRoute:
|
||||
soft_row: dict[str, Any]
|
||||
hard_row: dict[str, Any]
|
||||
hard_content_rng: Any
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"soft_row": self.soft_row,
|
||||
"hard_row": self.hard_row,
|
||||
"hard_content_rng": self.hard_content_rng,
|
||||
}
|
||||
|
||||
|
||||
def build_insta_pair_rows_result(
|
||||
*,
|
||||
row_number: int,
|
||||
start_index: int,
|
||||
seed: int,
|
||||
active_trigger: str,
|
||||
parsed_seed_config: dict[str, int],
|
||||
options: dict[str, Any],
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
character_profile: str | dict[str, Any] | None,
|
||||
character_cast: str | dict[str, Any] | list[Any] | None,
|
||||
character_slot_map: dict[str, dict[str, Any]],
|
||||
pov_character_labels: list[str],
|
||||
hard_women_count: int,
|
||||
hard_men_count: int,
|
||||
soft_category: str,
|
||||
soft_subcategory: str,
|
||||
softcore_level_key: str,
|
||||
hardcore_random_subcategory: str,
|
||||
hardcore_position_config: str | dict[str, Any] | None,
|
||||
location_config: str | dict[str, Any] | None,
|
||||
composition_config: str | dict[str, Any] | None,
|
||||
build_prompt: BuildPrompt,
|
||||
axis_rng: AxisRng,
|
||||
cast_expression_intensity_override: Callable[
|
||||
[float, dict[str, dict[str, Any]], int, int, str],
|
||||
tuple[float | None, str],
|
||||
],
|
||||
context_from_character_slot: Callable[[Any, dict[str, Any], str, str, str, bool, bool], dict[str, Any]],
|
||||
apply_character_context_to_row: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]],
|
||||
disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]],
|
||||
slot_softcore_outfit: Callable[[dict[str, Any] | None, Any], str],
|
||||
softcore_outfit: Callable[[Any, str], str],
|
||||
softcore_pose: Callable[[Any, str], str],
|
||||
softcore_item_prompt_label: Callable[[str], str],
|
||||
pov_prompt_directive: Callable[[list[str]], str],
|
||||
pov_composition_prompt: Callable[[Any, list[str]], str],
|
||||
) -> InstaPairRowsRoute:
|
||||
soft_content_rng = axis_rng(parsed_seed_config, "content", seed, row_number + 311)
|
||||
hard_content_rng = axis_rng(parsed_seed_config, "content", seed, row_number + 317)
|
||||
soft_person_rng = axis_rng(parsed_seed_config, "person", seed, row_number)
|
||||
|
||||
soft_expression_women_count = hard_women_count if options["softcore_cast"] == "same_as_hardcore" else 1
|
||||
soft_expression_men_count = hard_men_count if options["softcore_cast"] == "same_as_hardcore" else 0
|
||||
soft_expression_enabled = bool(options["softcore_expression_enabled"])
|
||||
soft_expression_intensity = options["softcore_expression_intensity"]
|
||||
soft_expression_intensity_source = "input"
|
||||
if soft_expression_enabled:
|
||||
soft_expression_intensity, soft_expression_intensity_source = cast_expression_intensity_override(
|
||||
options["softcore_expression_intensity"],
|
||||
character_slot_map,
|
||||
soft_expression_women_count,
|
||||
soft_expression_men_count,
|
||||
"softcore",
|
||||
)
|
||||
if soft_expression_intensity is None:
|
||||
soft_expression_enabled = False
|
||||
else:
|
||||
soft_expression_intensity_source = "disabled"
|
||||
|
||||
primary_slot = character_slot_map.get("Woman A")
|
||||
primary_slot_context = None
|
||||
if primary_slot:
|
||||
primary_slot_context = context_from_character_slot(
|
||||
soft_person_rng,
|
||||
primary_slot,
|
||||
"woman",
|
||||
ethnicity,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
)
|
||||
|
||||
soft_row = build_prompt(
|
||||
category=soft_category,
|
||||
subcategory=soft_subcategory,
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
clothing="minimal",
|
||||
ethnicity=ethnicity,
|
||||
poses="evocative",
|
||||
backside_bias=0.0,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
minimal_clothing_ratio=-1,
|
||||
standard_pose_ratio=-1,
|
||||
trigger=active_trigger,
|
||||
prepend_trigger_to_prompt=False,
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
seed_config=parsed_seed_config,
|
||||
women_count=1,
|
||||
men_count=0,
|
||||
expression_enabled=soft_expression_enabled,
|
||||
expression_intensity=soft_expression_intensity,
|
||||
character_profile="" if primary_slot else character_profile or "",
|
||||
character_cast="",
|
||||
location_config=location_config or "",
|
||||
composition_config=composition_config or "",
|
||||
)
|
||||
soft_row["expression_intensity_source"] = soft_expression_intensity_source
|
||||
if primary_slot_context:
|
||||
soft_row = apply_character_context_to_row(soft_row, primary_slot_context)
|
||||
soft_row["character_slot"] = primary_slot
|
||||
soft_row["character_slot_status"] = "applied:Woman A"
|
||||
if not soft_expression_enabled:
|
||||
soft_row = disable_row_expression(soft_row, soft_expression_intensity_source)
|
||||
|
||||
primary_softcore_outfit = slot_softcore_outfit(primary_slot, soft_content_rng)
|
||||
soft_row["item"] = primary_softcore_outfit or softcore_outfit(soft_content_rng, softcore_level_key)
|
||||
soft_row["pose"] = softcore_pose(soft_content_rng, softcore_level_key)
|
||||
soft_row["item_label"] = (
|
||||
"Insta/OF softcore body exposure"
|
||||
if softcore_level_key == "explicit_nude"
|
||||
else "Insta/OF softcore outfit"
|
||||
)
|
||||
soft_row["softcore_item_prompt_label"] = softcore_item_prompt_label(softcore_level_key)
|
||||
soft_row["custom_item"] = "insta_of_softcore_outfit"
|
||||
soft_row["softcore_outfit_policy"] = "character_slot:Woman A" if primary_softcore_outfit else "insta_of_safe_softcore"
|
||||
if softcore_level_key == "explicit_nude":
|
||||
soft_row["source_scene_text"] = soft_row.get("source_scene_text") or soft_row.get("scene_text", "")
|
||||
soft_row["scene_text"] = pair_clothing.body_exposure_scene_text(soft_row.get("scene_text", ""))
|
||||
soft_row["pov_character_labels"] = (
|
||||
pov_character_labels
|
||||
if options["softcore_cast"] == "same_as_hardcore"
|
||||
else []
|
||||
)
|
||||
soft_row["pov_prompt_directive"] = pov_prompt_directive(soft_row["pov_character_labels"])
|
||||
if soft_row["pov_character_labels"]:
|
||||
soft_row["source_composition"] = soft_row.get("source_composition") or soft_row.get("composition", "")
|
||||
soft_row["composition"] = pov_composition_prompt(
|
||||
soft_row["source_composition"],
|
||||
soft_row["pov_character_labels"],
|
||||
)
|
||||
|
||||
hard_row = build_prompt(
|
||||
category="Hardcore sexual poses",
|
||||
subcategory=hardcore_random_subcategory,
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
clothing="minimal",
|
||||
ethnicity=ethnicity,
|
||||
poses="evocative",
|
||||
backside_bias=0.0,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
minimal_clothing_ratio=-1,
|
||||
standard_pose_ratio=-1,
|
||||
trigger=active_trigger,
|
||||
prepend_trigger_to_prompt=False,
|
||||
extra_positive="",
|
||||
extra_negative="",
|
||||
seed_config=parsed_seed_config,
|
||||
women_count=hard_women_count,
|
||||
men_count=hard_men_count,
|
||||
expression_enabled=options["hardcore_expression_enabled"],
|
||||
expression_intensity=options["hardcore_expression_intensity"],
|
||||
character_cast=character_cast or "",
|
||||
expression_phase="hardcore",
|
||||
hardcore_position_config=hardcore_position_config or "",
|
||||
location_config=location_config or "",
|
||||
composition_config=composition_config or "",
|
||||
)
|
||||
hard_row["hardcore_detail_density"] = options["hardcore_detail_density"]
|
||||
hard_row["pov_character_labels"] = pov_character_labels
|
||||
hard_row["pov_prompt_directive"] = pov_prompt_directive(pov_character_labels)
|
||||
|
||||
return InstaPairRowsRoute(
|
||||
soft_row=soft_row,
|
||||
hard_row=hard_row,
|
||||
hard_content_rng=hard_content_rng,
|
||||
)
|
||||
|
||||
|
||||
def build_insta_pair_rows(
|
||||
*,
|
||||
row_number: int,
|
||||
start_index: int,
|
||||
seed: int,
|
||||
active_trigger: str,
|
||||
parsed_seed_config: dict[str, int],
|
||||
options: dict[str, Any],
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
character_profile: str | dict[str, Any] | None,
|
||||
character_cast: str | dict[str, Any] | list[Any] | None,
|
||||
character_slot_map: dict[str, dict[str, Any]],
|
||||
pov_character_labels: list[str],
|
||||
hard_women_count: int,
|
||||
hard_men_count: int,
|
||||
soft_category: str,
|
||||
soft_subcategory: str,
|
||||
softcore_level_key: str,
|
||||
hardcore_random_subcategory: str,
|
||||
hardcore_position_config: str | dict[str, Any] | None,
|
||||
location_config: str | dict[str, Any] | None,
|
||||
composition_config: str | dict[str, Any] | None,
|
||||
build_prompt: BuildPrompt,
|
||||
axis_rng: AxisRng,
|
||||
cast_expression_intensity_override: Callable[
|
||||
[float, dict[str, dict[str, Any]], int, int, str],
|
||||
tuple[float | None, str],
|
||||
],
|
||||
context_from_character_slot: Callable[[Any, dict[str, Any], str, str, str, bool, bool], dict[str, Any]],
|
||||
apply_character_context_to_row: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]],
|
||||
disable_row_expression: Callable[[dict[str, Any], str], dict[str, Any]],
|
||||
slot_softcore_outfit: Callable[[dict[str, Any] | None, Any], str],
|
||||
softcore_outfit: Callable[[Any, str], str],
|
||||
softcore_pose: Callable[[Any, str], str],
|
||||
softcore_item_prompt_label: Callable[[str], str],
|
||||
pov_prompt_directive: Callable[[list[str]], str],
|
||||
pov_composition_prompt: Callable[[Any, list[str]], str],
|
||||
) -> dict[str, Any]:
|
||||
return build_insta_pair_rows_result(
|
||||
row_number=row_number,
|
||||
start_index=start_index,
|
||||
seed=seed,
|
||||
active_trigger=active_trigger,
|
||||
parsed_seed_config=parsed_seed_config,
|
||||
options=options,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
character_profile=character_profile,
|
||||
character_cast=character_cast,
|
||||
character_slot_map=character_slot_map,
|
||||
pov_character_labels=pov_character_labels,
|
||||
hard_women_count=hard_women_count,
|
||||
hard_men_count=hard_men_count,
|
||||
soft_category=soft_category,
|
||||
soft_subcategory=soft_subcategory,
|
||||
softcore_level_key=softcore_level_key,
|
||||
hardcore_random_subcategory=hardcore_random_subcategory,
|
||||
hardcore_position_config=hardcore_position_config,
|
||||
location_config=location_config,
|
||||
composition_config=composition_config,
|
||||
build_prompt=build_prompt,
|
||||
axis_rng=axis_rng,
|
||||
cast_expression_intensity_override=cast_expression_intensity_override,
|
||||
context_from_character_slot=context_from_character_slot,
|
||||
apply_character_context_to_row=apply_character_context_to_row,
|
||||
disable_row_expression=disable_row_expression,
|
||||
slot_softcore_outfit=slot_softcore_outfit,
|
||||
softcore_outfit=softcore_outfit,
|
||||
softcore_pose=softcore_pose,
|
||||
softcore_item_prompt_label=softcore_item_prompt_label,
|
||||
pov_prompt_directive=pov_prompt_directive,
|
||||
pov_composition_prompt=pov_composition_prompt,
|
||||
).as_dict()
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def clean_pov_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)
|
||||
text = re.sub(r"(?:,\s*){2,}", ", ", text)
|
||||
text = re.sub(r"\.\s*\.", ".", text)
|
||||
text = re.sub(r":\s*\.", ".", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def slot_is_pov(slot: dict[str, Any] | None) -> bool:
|
||||
if not slot:
|
||||
return False
|
||||
return slot.get("subject_type") == "man" and slot.get("presence_mode") == "pov"
|
||||
|
||||
|
||||
def pov_labels_from_value(value: Any) -> list[str]:
|
||||
labels: list[str] = []
|
||||
if isinstance(value, list):
|
||||
candidates = value
|
||||
else:
|
||||
text = clean_pov_text(value)
|
||||
candidates = re.split(r"[,;]\s*", text) if text else []
|
||||
for candidate in candidates:
|
||||
label = clean_pov_text(candidate)
|
||||
if re.match(r"^Man [A-Z]$", label) and label not in labels:
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def merge_labels(*groups: list[str]) -> list[str]:
|
||||
merged: list[str] = []
|
||||
for group in groups:
|
||||
for label in group:
|
||||
if label and label not in merged:
|
||||
merged.append(label)
|
||||
return merged
|
||||
|
||||
|
||||
def pov_character_labels(
|
||||
label_map: dict[str, dict[str, Any]],
|
||||
men_count: int | None = None,
|
||||
) -> list[str]:
|
||||
if men_count is None:
|
||||
labels = sorted(label for label in label_map if label.startswith("Man "))
|
||||
else:
|
||||
labels = [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]
|
||||
return [label for label in labels if slot_is_pov(label_map.get(label))]
|
||||
|
||||
|
||||
def filter_pov_labeled_clauses(text: Any, pov_labels: list[str]) -> str:
|
||||
rendered = clean_pov_text(text)
|
||||
if not rendered or not pov_labels:
|
||||
return rendered
|
||||
clauses = [clause.strip() for clause in rendered.split(";") if clause.strip()]
|
||||
filtered = [
|
||||
clause
|
||||
for clause in clauses
|
||||
if not any(re.match(rf"^{re.escape(label)}\b", clause) for label in pov_labels)
|
||||
]
|
||||
return "; ".join(filtered)
|
||||
|
||||
|
||||
def pov_text_with_viewer(text: Any, pov_labels: list[str]) -> str:
|
||||
rendered = clean_pov_text(text)
|
||||
if not rendered or not pov_labels:
|
||||
return rendered
|
||||
for label in sorted(pov_labels, key=len, reverse=True):
|
||||
escaped = re.escape(label)
|
||||
rendered = re.sub(rf"\b{escaped}'s\b", "the POV viewer's", rendered)
|
||||
rendered = re.sub(rf"\b{escaped}\b", "the POV viewer", rendered)
|
||||
rendered = re.sub(
|
||||
r"\bthe POV viewer is positioned\b",
|
||||
"the POV camera is positioned",
|
||||
rendered,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return clean_pov_text(rendered)
|
||||
|
||||
|
||||
def pov_role_graph_prompt(role_graph: Any, pov_labels: list[str]) -> str:
|
||||
role_graph_text = clean_pov_text(role_graph)
|
||||
if not role_graph_text or not pov_labels:
|
||||
return role_graph_text
|
||||
viewer_text = pov_text_with_viewer(role_graph_text, pov_labels)
|
||||
label_text = ", ".join(pov_labels)
|
||||
return f"First-person POV from {label_text}; {viewer_text}"
|
||||
|
||||
|
||||
def pov_prompt_directive(pov_labels: list[str]) -> str:
|
||||
if not pov_labels:
|
||||
return ""
|
||||
label_text = ", ".join(pov_labels)
|
||||
return (
|
||||
f"POV participant: {label_text} is the first-person camera viewpoint; "
|
||||
"he remains the off-camera viewpoint, represented by foreground hands, body position, or camera perspective cues when needed."
|
||||
)
|
||||
|
||||
|
||||
def pov_composition_base_text(composition: Any, pov_labels: list[str]) -> str:
|
||||
text = clean_pov_text(composition)
|
||||
if not text or not pov_labels:
|
||||
return text
|
||||
text = re.sub(r"\ball participants visible\b", "visible partners readable", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\ball adult bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\ball bodies visible\b", "visible partners readable", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\ball three bodies readable\b", "visible partner bodies readable", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\bwide group-sex composition\b", "first-person group-sex POV composition", text, flags=re.IGNORECASE)
|
||||
return clean_pov_text(text)
|
||||
|
||||
|
||||
def pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str:
|
||||
text = pov_composition_base_text(composition, pov_labels)
|
||||
if not text or not pov_labels:
|
||||
return text
|
||||
if "pov" not in text.lower() and "first-person" not in text.lower():
|
||||
text = f"{text}, adapted for first-person POV with the POV participant kept off-camera"
|
||||
return clean_pov_text(text)
|
||||
|
||||
|
||||
def pov_composition_formatter_text(composition: Any, pov_labels: list[str]) -> str:
|
||||
text = pov_composition_base_text(composition, pov_labels)
|
||||
if not text or not pov_labels:
|
||||
return text
|
||||
text = re.sub(
|
||||
r",?\s*adapted for first-person POV with the POV participant kept off-camera\b",
|
||||
"",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(r",?\s*with the POV participant kept off-camera\b", "", text, flags=re.IGNORECASE)
|
||||
return clean_pov_text(text)
|
||||
+1443
-7497
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -51,7 +51,7 @@ def _strip_empty_fields(text: str) -> str:
|
||||
labels = "|".join(re.escape(label) for label in EMPTY_FIELD_LABELS)
|
||||
text = re.sub(rf"\b(?:{labels})\s*:\s*[.,;]", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(rf"\b(?:{labels}):\s*(?=\.|,|;|$)", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(rf"\b(?:{labels})\.(?=\s|$)", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(rf"(^|(?<=[.!?])\s+)(?:{labels})\.(?=\s|$)", r"\1", text, flags=re.IGNORECASE)
|
||||
text = re.sub(rf"\b(?:{labels}):\s*(?:none|null|n/a)\b[.,;]?", "", text, flags=re.IGNORECASE)
|
||||
return clean_spacing(text)
|
||||
|
||||
@@ -167,3 +167,8 @@ def sanitize_tag_prompt(value: Any, triggers: Iterable[str] = ()) -> str:
|
||||
|
||||
def sanitize_negative_text(value: Any) -> str:
|
||||
return dedupe_comma_list(value)
|
||||
|
||||
|
||||
def combine_negative_text(*parts: Any) -> str:
|
||||
cleaned = [clean_spacing(part).strip(" ,.;") for part in parts if clean_spacing(part).strip(" ,.;")]
|
||||
return sanitize_negative_text(", ".join(cleaned))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_template_metadata as template_metadata_policy
|
||||
from .hardcore_action_metadata import normalize_hardcore_action_family
|
||||
from .hardcore_position_config import normalize_hardcore_position_family, normalize_hardcore_position_values
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import category_template_metadata as template_metadata_policy
|
||||
from hardcore_action_metadata import normalize_hardcore_action_family
|
||||
from hardcore_position_config import normalize_hardcore_position_family, normalize_hardcore_position_values
|
||||
|
||||
|
||||
def row_action_family(row: Any, default: str = "") -> str:
|
||||
if not isinstance(row, dict):
|
||||
return default
|
||||
family = normalize_hardcore_action_family(row.get("action_family"), "")
|
||||
if family:
|
||||
return family
|
||||
metadata = row.get("item_template_metadata")
|
||||
if isinstance(metadata, dict):
|
||||
family = template_metadata_policy.template_action_family(metadata)
|
||||
if family:
|
||||
return family
|
||||
return default
|
||||
|
||||
|
||||
def row_position_family(row: Any, default: str = "") -> str:
|
||||
if not isinstance(row, dict):
|
||||
return default
|
||||
family = normalize_hardcore_position_family(str(row.get("position_family") or "").strip().lower(), "")
|
||||
if family:
|
||||
return family
|
||||
metadata = row.get("item_template_metadata")
|
||||
if isinstance(metadata, dict):
|
||||
family = template_metadata_policy.template_position_family(metadata)
|
||||
if family:
|
||||
return family
|
||||
return default
|
||||
|
||||
|
||||
def _raw_position_key_values(row: dict[str, Any]) -> list[Any]:
|
||||
values: list[Any] = []
|
||||
position_keys = row.get("position_keys")
|
||||
if isinstance(position_keys, list):
|
||||
values.extend(position_keys)
|
||||
elif position_keys is not None:
|
||||
values.append(position_keys)
|
||||
if row.get("position_key") is not None:
|
||||
values.append(row.get("position_key"))
|
||||
return values
|
||||
|
||||
|
||||
def _position_key_slug(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text or text == "any":
|
||||
return ""
|
||||
return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
||||
|
||||
|
||||
def row_position_keys(row: Any, *, include_unknown: bool = False) -> list[str]:
|
||||
if not isinstance(row, dict):
|
||||
return []
|
||||
values = _raw_position_key_values(row)
|
||||
selected = normalize_hardcore_position_values(values)
|
||||
metadata = row.get("item_template_metadata")
|
||||
if isinstance(metadata, dict):
|
||||
for key in template_metadata_policy.template_position_keys(metadata):
|
||||
if key and key not in selected:
|
||||
selected.append(key)
|
||||
if not include_unknown:
|
||||
return selected
|
||||
for value in values:
|
||||
normalized = _position_key_slug(value)
|
||||
if normalized and normalized not in selected:
|
||||
selected.append(normalized)
|
||||
return selected
|
||||
|
||||
|
||||
def row_formatter_hints(row: Any, route: str) -> list[str]:
|
||||
hints: list[str] = []
|
||||
for hint in template_metadata_policy.formatter_hints_for_route(row, route):
|
||||
if hint not in hints:
|
||||
hints.append(hint)
|
||||
if isinstance(row, dict) and isinstance(row.get("item_template_metadata"), dict):
|
||||
for hint in template_metadata_policy.formatter_hints_for_route(row["item_template_metadata"], route):
|
||||
if hint not in hints:
|
||||
hints.append(hint)
|
||||
return hints
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import generate_prompt_batches as g
|
||||
from . import pov_policy
|
||||
from . import row_camera as row_camera_policy
|
||||
from . import row_expression as row_expression_policy
|
||||
from . import row_rendering as row_rendering_policy
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import generate_prompt_batches as g
|
||||
import pov_policy
|
||||
import row_camera as row_camera_policy
|
||||
import row_expression as row_expression_policy
|
||||
import row_rendering as row_rendering_policy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomRowAssemblyRequest:
|
||||
row_number: int
|
||||
start_index: int
|
||||
category: dict[str, Any]
|
||||
subcategory: dict[str, Any]
|
||||
item: Any
|
||||
context: dict[str, Any]
|
||||
subject_type: str
|
||||
item_text: str
|
||||
item_name: str
|
||||
item_axis_values: dict[str, Any]
|
||||
item_template_metadata: dict[str, Any]
|
||||
formatter_hints: dict[str, Any]
|
||||
item_label: str
|
||||
style: str
|
||||
positive_suffix: str
|
||||
negative_prompt: str
|
||||
scene_slug: str
|
||||
scene: str
|
||||
scene_entry: dict[str, Any]
|
||||
pose: str
|
||||
expression: str
|
||||
shared_expression: str
|
||||
character_expressions: list[str]
|
||||
character_expression_text: str
|
||||
expression_disabled: bool
|
||||
expression_intensity: float | None
|
||||
expression_intensity_source: str
|
||||
composition: str
|
||||
source_composition: str
|
||||
composition_entry: dict[str, Any]
|
||||
role_graph: str
|
||||
source_role_graph: str
|
||||
action_family: str
|
||||
position_family: str
|
||||
position_key: str
|
||||
position_keys: list[str]
|
||||
pov_character_labels: list[str]
|
||||
cast_descriptors: list[str]
|
||||
cast_descriptor_text: str
|
||||
seed_config: dict[str, Any]
|
||||
hardcore_position_config: dict[str, Any] | None = None
|
||||
location_config: dict[str, Any] | None = None
|
||||
composition_config: dict[str, Any] | None = None
|
||||
content_seed_axis: str = "content"
|
||||
count_adjustment: dict[str, Any] | None = None
|
||||
applied_profile: dict[str, Any] | None = None
|
||||
profile_status: str = "none"
|
||||
applied_slot: dict[str, Any] | None = None
|
||||
slot_status: str = "none"
|
||||
character_slots: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def assemble_custom_row(request: CustomRowAssemblyRequest) -> dict[str, Any]:
|
||||
r = request
|
||||
render_context = dict(r.context)
|
||||
pov_prompt_directive = pov_policy.pov_prompt_directive(r.pov_character_labels)
|
||||
render_context.update(
|
||||
{
|
||||
"trigger": g.TRIGGER,
|
||||
"main_category": r.category["name"],
|
||||
"subcategory": r.subcategory["name"],
|
||||
"category": r.category["name"],
|
||||
"item": r.item_text,
|
||||
"item_name": r.item_name,
|
||||
"item_label": r.item_label,
|
||||
"style": r.style,
|
||||
"scene": r.scene,
|
||||
"scene_slug": r.scene_slug,
|
||||
"scene_entry": r.scene_entry,
|
||||
"pose": r.pose,
|
||||
"expression": r.expression,
|
||||
"shared_expression": r.shared_expression,
|
||||
"character_expressions": r.character_expressions,
|
||||
"character_expression_text": r.character_expression_text,
|
||||
"expression_enabled": not r.expression_disabled,
|
||||
"expression_disabled": r.expression_disabled,
|
||||
"expression_intensity": r.expression_intensity,
|
||||
"expression_intensity_source": r.expression_intensity_source,
|
||||
"composition": r.composition,
|
||||
"composition_entry": r.composition_entry,
|
||||
"source_composition": r.source_composition,
|
||||
"composition_prompt": row_camera_policy.composition_prompt(r.composition),
|
||||
"composition_config": r.composition_config or {},
|
||||
"role_graph": r.role_graph,
|
||||
"source_role_graph": r.source_role_graph,
|
||||
"action_family": r.action_family,
|
||||
"position_family": r.position_family,
|
||||
"position_key": r.position_key,
|
||||
"position_keys": r.position_keys,
|
||||
"pov_character_labels": r.pov_character_labels,
|
||||
"pov_prompt_directive": pov_prompt_directive,
|
||||
"cast_descriptors": r.cast_descriptor_text,
|
||||
"positive_suffix": r.positive_suffix,
|
||||
"negative_prompt": r.negative_prompt,
|
||||
}
|
||||
)
|
||||
rendered = row_rendering_policy.render_prompt_caption(
|
||||
item=r.item,
|
||||
subcategory=r.subcategory,
|
||||
category=r.category,
|
||||
subject_type=r.subject_type,
|
||||
context=render_context,
|
||||
cast_descriptor_text=r.cast_descriptor_text,
|
||||
pov_prompt_directive=pov_prompt_directive if r.pov_character_labels else "",
|
||||
)
|
||||
batch = max(1, ((r.row_number - 1) // g.BATCH_SIZE) + 1)
|
||||
index = r.start_index + r.row_number - 1
|
||||
row = g.row_base(
|
||||
index,
|
||||
batch,
|
||||
render_context["subject"],
|
||||
render_context["age"],
|
||||
render_context["body"],
|
||||
r.scene_slug,
|
||||
r.composition,
|
||||
)
|
||||
row.update(
|
||||
{
|
||||
"prompt": rendered["prompt"],
|
||||
"caption": rendered["caption"],
|
||||
"negative_prompt": r.negative_prompt,
|
||||
"expression": r.expression,
|
||||
"main_category": r.category["name"],
|
||||
"subcategory": r.subcategory["name"],
|
||||
"category_slug": r.category["slug"],
|
||||
"subcategory_slug": r.subcategory["slug"],
|
||||
"subject_type": r.subject_type,
|
||||
"subject_phrase": render_context.get("subject_phrase", ""),
|
||||
"body_phrase": render_context.get("body_phrase", ""),
|
||||
"skin": render_context.get("skin", ""),
|
||||
"hair": render_context.get("hair", ""),
|
||||
"eyes": render_context.get("eyes", ""),
|
||||
"style": r.style,
|
||||
"item": r.item_text,
|
||||
"item_label": r.item_label,
|
||||
"positive_suffix": r.positive_suffix,
|
||||
"custom_item": r.item_name,
|
||||
"item_axis_values": r.item_axis_values,
|
||||
"item_template_metadata": r.item_template_metadata,
|
||||
"formatter_hints": r.formatter_hints,
|
||||
"scene_text": r.scene,
|
||||
"scene_entry": r.scene_entry,
|
||||
"location_theme": (r.location_config or {}).get("theme", ""),
|
||||
"scene_theme": r.scene_entry.get("theme", "") or (
|
||||
(r.location_config or {}).get("theme", "")
|
||||
if (r.location_config or {}).get("apply_mode") == "replace"
|
||||
else ""
|
||||
),
|
||||
"location_config": r.location_config or {},
|
||||
"pose": r.pose,
|
||||
"seed_config": r.seed_config,
|
||||
"hardcore_position_config": r.hardcore_position_config or {},
|
||||
"content_seed_axis": r.content_seed_axis,
|
||||
"role_graph": r.role_graph,
|
||||
"source_role_graph": r.source_role_graph,
|
||||
"action_family": r.action_family,
|
||||
"position_family": r.position_family,
|
||||
"position_key": r.position_key,
|
||||
"position_keys": r.position_keys,
|
||||
"source_composition": r.source_composition,
|
||||
"composition_entry": r.composition_entry,
|
||||
"composition_theme": (r.composition_config or {}).get("theme", ""),
|
||||
"pov_character_labels": r.pov_character_labels,
|
||||
"pov_prompt_directive": pov_prompt_directive,
|
||||
"shared_expression": r.shared_expression,
|
||||
"character_expressions": r.character_expressions,
|
||||
"character_expression_text": r.character_expression_text,
|
||||
"expression_enabled": not r.expression_disabled,
|
||||
"expression_disabled": r.expression_disabled,
|
||||
"cast_summary": render_context.get("cast_summary", ""),
|
||||
"cast_descriptors": r.cast_descriptors,
|
||||
"cast_descriptor_text": r.cast_descriptor_text,
|
||||
"scene_kind": render_context.get("scene_kind", ""),
|
||||
"women_count": render_context.get("women_count", ""),
|
||||
"men_count": render_context.get("men_count", ""),
|
||||
"person_count": render_context.get("person_count", ""),
|
||||
"cast_count_adjustment": r.count_adjustment if r.subject_type == "configured_cast" else {},
|
||||
"character_profile": r.applied_profile or {},
|
||||
"character_profile_status": r.profile_status,
|
||||
"character_slot": r.applied_slot or {},
|
||||
"character_slot_status": r.slot_status,
|
||||
"character_cast_slots": r.character_slots or [],
|
||||
"expression_intensity": r.expression_intensity,
|
||||
"expression_intensity_source": r.expression_intensity_source,
|
||||
"source": "json_category",
|
||||
}
|
||||
)
|
||||
if render_context.get("figure"):
|
||||
row["figure"] = render_context["figure"]
|
||||
if r.expression_disabled:
|
||||
row = row_expression_policy.disable_row_expression(row, r.expression_intensity_source)
|
||||
return row
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
try:
|
||||
from . import camera_config as camera_policy
|
||||
from . import scene_camera_adapters
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import camera_config as camera_policy
|
||||
import scene_camera_adapters
|
||||
|
||||
|
||||
PovLabelResolver = Callable[[dict[str, Any]], list[str]]
|
||||
|
||||
|
||||
def _list_from(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def composition_prompt(composition: Any) -> str:
|
||||
composition = str(composition or "").strip()
|
||||
if not composition:
|
||||
return composition
|
||||
lower = composition.lower()
|
||||
if lower.startswith("vertical ") or " vertical " in lower or lower.endswith(" vertical"):
|
||||
return composition
|
||||
return f"vertical {composition}"
|
||||
|
||||
|
||||
def insert_positive_directive(prompt: str, directive: str) -> str:
|
||||
marker = " Avoid:"
|
||||
if marker in prompt:
|
||||
before, after = prompt.split(marker, 1)
|
||||
return f"{before.rstrip()} {directive}{marker}{after}"
|
||||
return f"{prompt.rstrip()} {directive}"
|
||||
|
||||
|
||||
def camera_caption_text(parsed: dict[str, Any]) -> str:
|
||||
return camera_policy.camera_caption_text(parsed)
|
||||
|
||||
|
||||
def coworking_composition_prompt(scene_text: Any, composition: Any, subject_kind: str = "subjects") -> str:
|
||||
return scene_camera_adapters.coworking_composition_prompt(scene_text, composition, subject_kind)
|
||||
|
||||
|
||||
def row_scene_text(row: dict[str, Any]) -> Any:
|
||||
return row.get("scene_text") or row.get("source_scene_text") or row.get("scene")
|
||||
|
||||
|
||||
def row_scene_theme(row: dict[str, Any]) -> str:
|
||||
return str(row.get("scene_theme") or row.get("location_theme") or "")
|
||||
|
||||
|
||||
def row_scene_profile_key(row: dict[str, Any]) -> str:
|
||||
return str(row.get("scene_camera_profile_key") or "")
|
||||
|
||||
|
||||
def apply_contextual_composition(row: dict[str, Any], subject_kind: str) -> dict[str, Any]:
|
||||
scene_text = row_scene_text(row)
|
||||
old_composition = str(row.get("composition") or "").strip()
|
||||
new_composition = scene_camera_adapters.contextual_composition_prompt(
|
||||
scene_text,
|
||||
old_composition,
|
||||
subject_kind,
|
||||
scene_entry=row.get("scene_entry"),
|
||||
theme=row_scene_theme(row),
|
||||
profile_key=row_scene_profile_key(row),
|
||||
)
|
||||
if not old_composition or new_composition == old_composition:
|
||||
return row
|
||||
row["source_composition"] = row.get("source_composition") or old_composition
|
||||
row["composition"] = new_composition
|
||||
row["composition_prompt"] = composition_prompt(new_composition)
|
||||
prompt = str(row.get("prompt") or "")
|
||||
replacements = (
|
||||
(f"Composition: vertical {old_composition}.", f"Composition: {composition_prompt(new_composition)}."),
|
||||
(f"Composition: {old_composition}.", f"Composition: {composition_prompt(new_composition)}."),
|
||||
(f"Framed as {old_composition}.", f"Framed as {new_composition}."),
|
||||
)
|
||||
for old_fragment, new_fragment in replacements:
|
||||
if old_fragment in prompt:
|
||||
row["prompt"] = prompt.replace(old_fragment, new_fragment)
|
||||
break
|
||||
row["caption"] = str(row.get("caption") or "").replace(f", {old_composition},", f", {new_composition},")
|
||||
return row
|
||||
|
||||
|
||||
def scene_camera_profile_metadata(
|
||||
scene_text: Any = "",
|
||||
*,
|
||||
scene_entry: Any = None,
|
||||
theme: Any = "",
|
||||
profile_key: Any = "",
|
||||
) -> dict[str, str]:
|
||||
profile = scene_camera_adapters.scene_camera_profile(
|
||||
scene_text,
|
||||
scene_entry=scene_entry,
|
||||
theme=theme,
|
||||
profile_key=profile_key,
|
||||
)
|
||||
if not profile:
|
||||
return {}
|
||||
return {
|
||||
"key": str(profile.get("key") or ""),
|
||||
"family": str(profile.get("family") or ""),
|
||||
"layout_label": str(profile.get("layout_label") or ""),
|
||||
"place": str(profile.get("place") or ""),
|
||||
}
|
||||
|
||||
|
||||
def camera_scene_directive_for_context(
|
||||
scene_text: Any,
|
||||
composition: Any,
|
||||
camera_config: str | dict[str, Any] | None,
|
||||
pov_labels: list[str] | None = None,
|
||||
subject_kind: str = "subjects",
|
||||
compact_labels: Mapping[str, str] | None = None,
|
||||
*,
|
||||
scene_entry: Any = None,
|
||||
theme: Any = "",
|
||||
profile_key: Any = "",
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
parsed = camera_policy.parse_camera_config(camera_config)
|
||||
directive = scene_camera_adapters.camera_scene_directive_for_context(
|
||||
scene_text,
|
||||
parsed,
|
||||
pov_labels,
|
||||
subject_kind,
|
||||
compact_labels,
|
||||
scene_entry=scene_entry,
|
||||
theme=theme,
|
||||
profile_key=profile_key,
|
||||
)
|
||||
return directive, parsed
|
||||
|
||||
|
||||
def row_camera_subject_kind(row: dict[str, Any]) -> str:
|
||||
subject_type = str(row.get("subject_type") or row.get("primary_subject") or "").lower()
|
||||
if subject_type in ("woman", "adult woman") or subject_type == "single_any":
|
||||
return "woman"
|
||||
if subject_type in ("man", "adult man"):
|
||||
return "man"
|
||||
try:
|
||||
women_count = int(row.get("women_count") or 0)
|
||||
men_count = int(row.get("men_count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
women_count = men_count = 0
|
||||
if women_count == 1 and men_count == 0:
|
||||
return "woman"
|
||||
if women_count == 0 and men_count == 1:
|
||||
return "man"
|
||||
if women_count + men_count == 2:
|
||||
return "couple"
|
||||
return "subjects"
|
||||
|
||||
|
||||
def row_pov_labels(row: dict[str, Any], resolver: PovLabelResolver | None = None) -> list[str]:
|
||||
resolved: list[str] = []
|
||||
if resolver is not None:
|
||||
resolved = [str(label) for label in _list_from(resolver(row)) if str(label).strip()]
|
||||
if resolved:
|
||||
return resolved
|
||||
return [str(label) for label in _list_from(row.get("pov_character_labels")) if str(label).strip()]
|
||||
|
||||
|
||||
def apply_camera_config(
|
||||
row: dict[str, Any],
|
||||
camera_config: str | dict[str, Any] | None,
|
||||
*,
|
||||
pov_label_resolver: PovLabelResolver | None = None,
|
||||
compact_labels: Mapping[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
directive, parsed = camera_policy.camera_directive(camera_config)
|
||||
pov_labels = row_pov_labels(row, pov_label_resolver)
|
||||
subject_kind = row_camera_subject_kind(row)
|
||||
row = apply_contextual_composition(row, subject_kind)
|
||||
profile_metadata = scene_camera_profile_metadata(
|
||||
row_scene_text(row),
|
||||
scene_entry=row.get("scene_entry"),
|
||||
theme=row_scene_theme(row),
|
||||
profile_key=row_scene_profile_key(row),
|
||||
)
|
||||
if profile_metadata:
|
||||
row["scene_camera_profile"] = profile_metadata
|
||||
row["scene_camera_profile_key"] = profile_metadata.get("key", "")
|
||||
scene_directive, parsed = camera_scene_directive_for_context(
|
||||
row_scene_text(row),
|
||||
row.get("composition") or row.get("source_composition"),
|
||||
parsed,
|
||||
pov_labels,
|
||||
subject_kind,
|
||||
compact_labels,
|
||||
scene_entry=row.get("scene_entry"),
|
||||
theme=row_scene_theme(row),
|
||||
profile_key=row_scene_profile_key(row),
|
||||
)
|
||||
row["camera_config"] = parsed
|
||||
row["camera_scene_directive"] = scene_directive
|
||||
row["camera_directive"] = "" if pov_labels else directive
|
||||
combined_directive = " ".join(part for part in (scene_directive, row["camera_directive"]) if part)
|
||||
if not combined_directive:
|
||||
return row
|
||||
row["prompt"] = insert_positive_directive(str(row.get("prompt") or ""), combined_directive)
|
||||
caption = camera_caption_text(parsed)
|
||||
if caption and not pov_labels:
|
||||
row["caption"] = f"{row.get('caption', '').rstrip()}, {caption}"
|
||||
return row
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import category_template_metadata as template_policy
|
||||
from . import hardcore_position_config as hardcore_position_policy
|
||||
from . import row_item as row_item_policy
|
||||
from . import seed_config as seed_policy
|
||||
from .hardcore_text_cleanup import (
|
||||
sanitize_hardcore_axis_values,
|
||||
sanitize_hardcore_environment_anchors,
|
||||
)
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import category_library as category_policy
|
||||
import category_template_metadata as template_policy
|
||||
import hardcore_position_config as hardcore_position_policy
|
||||
import row_item as row_item_policy
|
||||
import seed_config as seed_policy
|
||||
from hardcore_text_cleanup import (
|
||||
sanitize_hardcore_axis_values,
|
||||
sanitize_hardcore_environment_anchors,
|
||||
)
|
||||
|
||||
|
||||
def _list_from(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
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()
|
||||
tokens = set(re.findall(r"[a-z0-9]+", haystack))
|
||||
return bool(tokens.intersection({"pose", "poses", "sex", "sexual"}))
|
||||
|
||||
|
||||
def cast_count_adjustment(
|
||||
requested_women_count: int,
|
||||
requested_men_count: int,
|
||||
effective_women_count: int,
|
||||
effective_men_count: int,
|
||||
) -> dict[str, int]:
|
||||
if requested_women_count == effective_women_count and requested_men_count == effective_men_count:
|
||||
return {}
|
||||
return {
|
||||
"requested_women_count": requested_women_count,
|
||||
"requested_men_count": requested_men_count,
|
||||
"effective_women_count": effective_women_count,
|
||||
"effective_men_count": effective_men_count,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CategoryItemRoute:
|
||||
category: dict[str, Any]
|
||||
subcategory: dict[str, Any]
|
||||
women_count: int
|
||||
men_count: int
|
||||
count_adjustment: dict[str, int]
|
||||
content_axis: str
|
||||
item: Any
|
||||
item_text: str
|
||||
item_name: str
|
||||
item_axis_values: dict[str, Any]
|
||||
item_template_metadata: dict[str, Any]
|
||||
formatter_hints: dict[str, Any]
|
||||
is_pose_category: bool
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"category": self.category,
|
||||
"subcategory": self.subcategory,
|
||||
"women_count": self.women_count,
|
||||
"men_count": self.men_count,
|
||||
"count_adjustment": dict(self.count_adjustment),
|
||||
"content_axis": self.content_axis,
|
||||
"item": self.item,
|
||||
"item_text": self.item_text,
|
||||
"item_name": self.item_name,
|
||||
"item_axis_values": dict(self.item_axis_values),
|
||||
"item_template_metadata": dict(self.item_template_metadata),
|
||||
"formatter_hints": dict(self.formatter_hints),
|
||||
"is_pose_category": self.is_pose_category,
|
||||
}
|
||||
|
||||
|
||||
def select_category_item_route_result(
|
||||
*,
|
||||
category_choice: str,
|
||||
subcategory_choice: str,
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
hardcore_position_config: dict[str, Any] | None = None,
|
||||
categories: list[dict[str, Any]] | None = None,
|
||||
) -> CategoryItemRoute:
|
||||
source_categories = category_policy.load_category_library() if categories is None else categories
|
||||
parsed_hardcore_position_config = hardcore_position_config or {}
|
||||
requested_women_count = women_count
|
||||
requested_men_count = men_count
|
||||
|
||||
category_rng = seed_policy.axis_rng(seed_config, "category", seed, row_number)
|
||||
subcategory_rng = seed_policy.axis_rng(seed_config, "subcategory", seed, row_number)
|
||||
filtered_categories = hardcore_position_policy.filter_hardcore_categories_for_position(
|
||||
source_categories,
|
||||
parsed_hardcore_position_config,
|
||||
women_count,
|
||||
men_count,
|
||||
category_policy.compatible_entry,
|
||||
)
|
||||
category, subcategory, women_count, men_count = category_policy.find_subcategory(
|
||||
filtered_categories,
|
||||
category_choice,
|
||||
subcategory_choice,
|
||||
category_rng,
|
||||
subcategory_rng,
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
count_adjustment = cast_count_adjustment(
|
||||
requested_women_count,
|
||||
requested_men_count,
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
if hardcore_position_policy.is_hardcore_sexual_category(category):
|
||||
subcategory = hardcore_position_policy.apply_hardcore_position_config_to_subcategory(
|
||||
subcategory,
|
||||
parsed_hardcore_position_config,
|
||||
)
|
||||
|
||||
is_pose_category = is_pose_content_category(category, subcategory)
|
||||
content_axis = "pose" if is_pose_category else "content"
|
||||
content_rng = seed_policy.axis_rng(seed_config, content_axis, seed, row_number)
|
||||
item = row_item_policy.weighted_choice(content_rng, _list_from(subcategory.get("items", [subcategory["name"]])))
|
||||
item_text, item_name, item_axis_values, item_template_metadata = row_item_policy.compose_item(
|
||||
content_rng,
|
||||
category,
|
||||
subcategory,
|
||||
item,
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
if is_pose_category:
|
||||
item_text = sanitize_hardcore_environment_anchors(item_text)
|
||||
item_axis_values = sanitize_hardcore_axis_values(item_axis_values)
|
||||
|
||||
return CategoryItemRoute(
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
women_count=women_count,
|
||||
men_count=men_count,
|
||||
count_adjustment=count_adjustment,
|
||||
content_axis=content_axis,
|
||||
item=item,
|
||||
item_text=item_text,
|
||||
item_name=item_name,
|
||||
item_axis_values=item_axis_values,
|
||||
item_template_metadata=item_template_metadata,
|
||||
formatter_hints=template_policy.formatter_hints(item_template_metadata),
|
||||
is_pose_category=is_pose_category,
|
||||
)
|
||||
|
||||
|
||||
def select_category_item_route(
|
||||
*,
|
||||
category_choice: str,
|
||||
subcategory_choice: str,
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
hardcore_position_config: dict[str, Any] | None = None,
|
||||
categories: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return select_category_item_route_result(
|
||||
category_choice=category_choice,
|
||||
subcategory_choice=subcategory_choice,
|
||||
seed_config=seed_config,
|
||||
seed=seed,
|
||||
row_number=row_number,
|
||||
women_count=women_count,
|
||||
men_count=men_count,
|
||||
hardcore_position_config=hardcore_position_config,
|
||||
categories=categories,
|
||||
).as_dict()
|
||||
@@ -0,0 +1,455 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import character_slot as character_slot_policy
|
||||
from . import pov_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import category_library as category_policy
|
||||
import character_slot as character_slot_policy
|
||||
import pov_policy
|
||||
|
||||
|
||||
def clean_prompt_punctuation(text: str) -> str:
|
||||
text = re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
text = re.sub(r"\s+([,.;:])", r"\1", text)
|
||||
text = re.sub(r"(?:,\s*){2,}", ", ", text)
|
||||
text = re.sub(r"\.\s*\.", ".", text)
|
||||
text = re.sub(r":\s*\.", ".", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def strip_expression_text(text: str, expression: Any = "") -> str:
|
||||
text = str(text or "")
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"\s*Facial expressions?:\s*[^.]*\.\s*", " ", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r",\s*one with [^,]+ and the other with [^,]+(?=,)", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r",\s*a lively mix of expressions from [^,]+(?=,)", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\s+with\s+(?:an?|the)\s+[^,]*expression(?=,)", "", text, flags=re.IGNORECASE)
|
||||
expression_text = str(expression or "").strip()
|
||||
if expression_text:
|
||||
for part in [piece.strip() for piece in expression_text.split(";") if piece.strip()]:
|
||||
escaped = re.escape(part)
|
||||
text = re.sub(rf",\s*{escaped}(?=,)", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(rf"\s+with\s+(?:an?|the)?\s*{escaped}", "", text, flags=re.IGNORECASE)
|
||||
return clean_prompt_punctuation(text)
|
||||
|
||||
|
||||
def disable_row_expression(row: dict[str, Any], source: str = "disabled") -> dict[str, Any]:
|
||||
previous_expression = row.get("expression", "")
|
||||
row["prompt"] = strip_expression_text(row.get("prompt", ""), previous_expression)
|
||||
row["caption"] = strip_expression_text(row.get("caption", ""), previous_expression)
|
||||
row["expression"] = ""
|
||||
row["shared_expression"] = ""
|
||||
row["character_expressions"] = []
|
||||
row["character_expression_text"] = ""
|
||||
row["expression_enabled"] = False
|
||||
row["expression_disabled"] = True
|
||||
row["expression_intensity"] = None
|
||||
row["expression_intensity_source"] = source
|
||||
return row
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpressionRoute:
|
||||
expression_disabled: bool
|
||||
expression_intensity: float | None
|
||||
expression_intensity_source: str
|
||||
|
||||
|
||||
def resolve_expression_route(
|
||||
*,
|
||||
expression_enabled: bool,
|
||||
expression_intensity: float,
|
||||
expression_intensity_source: str,
|
||||
subject_type: str,
|
||||
applied_slot: dict[str, Any] | None = None,
|
||||
character_slots: list[dict[str, Any]] | None = None,
|
||||
character_slot_map: dict[str, dict[str, Any]] | None = None,
|
||||
women_count: int = 1,
|
||||
men_count: int = 1,
|
||||
expression_phase: str = "",
|
||||
) -> ExpressionRoute:
|
||||
source = expression_intensity_source or "input"
|
||||
disabled = not bool(expression_enabled)
|
||||
intensity: float | None = expression_intensity
|
||||
if disabled:
|
||||
source = "disabled"
|
||||
elif subject_type in ("woman", "man") and applied_slot:
|
||||
slot_label = "Woman A" if subject_type == "woman" else "Man A"
|
||||
if not character_slot_policy.slot_expression_enabled(applied_slot):
|
||||
disabled = True
|
||||
source = f"character_slot:{slot_label}:disabled"
|
||||
else:
|
||||
slot_expression_intensity = character_slot_policy.slot_expression_intensity_for_phase(
|
||||
applied_slot,
|
||||
expression_phase,
|
||||
)
|
||||
if slot_expression_intensity is not None:
|
||||
intensity = slot_expression_intensity
|
||||
source = f"character_slot:{slot_label}"
|
||||
elif subject_type == "configured_cast" and character_slots:
|
||||
intensity, source = cast_expression_intensity_override(
|
||||
expression_intensity,
|
||||
character_slot_map or {},
|
||||
women_count,
|
||||
men_count,
|
||||
expression_phase,
|
||||
)
|
||||
if intensity is None:
|
||||
disabled = True
|
||||
return ExpressionRoute(
|
||||
expression_disabled=disabled,
|
||||
expression_intensity=intensity,
|
||||
expression_intensity_source=source,
|
||||
)
|
||||
|
||||
|
||||
def _clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(min_value, min(max_value, number))
|
||||
|
||||
|
||||
def _entry_text(entry: Any) -> str:
|
||||
return category_policy._entry_text(entry)
|
||||
|
||||
|
||||
def expression_intensity_hint(entry: Any) -> float:
|
||||
if isinstance(entry, dict):
|
||||
for key in ("expression_intensity", "intensity"):
|
||||
if key in entry:
|
||||
return _clamped_float(entry[key], 0.5)
|
||||
|
||||
text = _entry_text(entry).lower()
|
||||
high_terms = (
|
||||
"ahegao",
|
||||
"orgasm",
|
||||
"climax",
|
||||
"drool",
|
||||
"drooling",
|
||||
"tongue out",
|
||||
"eyes rolled",
|
||||
"fucked-out",
|
||||
"cum-smeared",
|
||||
"saliva",
|
||||
"gagging",
|
||||
"slack jaw",
|
||||
"jaw slack",
|
||||
"slack-jawed",
|
||||
"sex-drunk",
|
||||
"overwhelmed",
|
||||
"strained",
|
||||
"messy",
|
||||
"panting",
|
||||
"trembling",
|
||||
"shaking",
|
||||
"wide open mouth",
|
||||
"raw ",
|
||||
"wild ",
|
||||
"dazed",
|
||||
"spent",
|
||||
)
|
||||
if any(term in text for term in high_terms):
|
||||
return 0.9
|
||||
|
||||
medium_terms = (
|
||||
"seductive",
|
||||
"teasing",
|
||||
"lustful",
|
||||
"aroused",
|
||||
"bedroom",
|
||||
"dominant",
|
||||
"predatory",
|
||||
"control",
|
||||
"stern",
|
||||
"strict",
|
||||
"smirk",
|
||||
"parted lips",
|
||||
"open-mouthed",
|
||||
"heated",
|
||||
"hungry",
|
||||
"inviting",
|
||||
"sensual",
|
||||
"fetish",
|
||||
"commanding",
|
||||
"flushed",
|
||||
"moan",
|
||||
)
|
||||
if any(term in text for term in medium_terms):
|
||||
return 0.62
|
||||
|
||||
low_terms = (
|
||||
"neutral",
|
||||
"quiet",
|
||||
"calm",
|
||||
"reserved",
|
||||
"relaxed",
|
||||
"candid",
|
||||
"closed-mouth",
|
||||
"thoughtful",
|
||||
"controlled",
|
||||
"focused",
|
||||
"steady",
|
||||
"bitten-lip",
|
||||
"braced",
|
||||
"held breath",
|
||||
"concentrated",
|
||||
"aloof",
|
||||
"bored",
|
||||
"tired",
|
||||
"unfocused",
|
||||
"contented",
|
||||
"fashion",
|
||||
"soft",
|
||||
"sleepy",
|
||||
"fresh-faced",
|
||||
)
|
||||
if any(term in text for term in low_terms):
|
||||
return 0.25
|
||||
return 0.5
|
||||
|
||||
|
||||
def expression_entries_for_intensity(entries: list[Any], expression_intensity: float) -> list[Any]:
|
||||
target = _clamped_float(expression_intensity, 0.5)
|
||||
weighted: list[Any] = []
|
||||
for entry in entries:
|
||||
entry_intensity = expression_intensity_hint(entry)
|
||||
distance = abs(target - entry_intensity)
|
||||
if distance <= 0.18:
|
||||
intensity_weight = 4.0
|
||||
elif distance <= 0.35:
|
||||
intensity_weight = 1.4
|
||||
elif distance <= 0.55:
|
||||
intensity_weight = 0.35
|
||||
else:
|
||||
intensity_weight = 0.05
|
||||
|
||||
if isinstance(entry, dict):
|
||||
adjusted = dict(entry)
|
||||
try:
|
||||
base_weight = float(adjusted.get("weight", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
base_weight = 1.0
|
||||
adjusted["weight"] = max(0.0, base_weight) * intensity_weight
|
||||
weighted.append(adjusted)
|
||||
else:
|
||||
weighted.append({"text": _entry_text(entry), "weight": intensity_weight})
|
||||
return weighted or entries
|
||||
|
||||
|
||||
def _mean(values: list[float]) -> float:
|
||||
return sum(values) / len(values)
|
||||
|
||||
|
||||
def cast_expression_intensity_override(
|
||||
fallback: float,
|
||||
label_map: dict[str, dict[str, Any]],
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
expression_phase: str = "",
|
||||
) -> tuple[float | None, str]:
|
||||
groups: list[tuple[str, list[str]]] = [
|
||||
("women", [f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))]),
|
||||
("men", [f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))]),
|
||||
]
|
||||
all_values: list[float] = []
|
||||
matching_slots: list[dict[str, Any]] = []
|
||||
for group_name, labels in groups:
|
||||
values: list[float] = []
|
||||
value_labels: list[str] = []
|
||||
for label in labels:
|
||||
slot = label_map.get(label)
|
||||
if pov_policy.slot_is_pov(slot):
|
||||
continue
|
||||
if slot:
|
||||
matching_slots.append(slot)
|
||||
value = character_slot_policy.slot_expression_intensity_for_phase(slot, expression_phase)
|
||||
if value is not None:
|
||||
values.append(value)
|
||||
value_labels.append(label)
|
||||
all_values.append(value)
|
||||
if values:
|
||||
if len(values) == 1:
|
||||
return values[0], f"character_slot:{value_labels[0]}"
|
||||
return _mean(values), f"character_slots:{group_name}"
|
||||
if all_values:
|
||||
return _mean(all_values), "character_slots:cast"
|
||||
if matching_slots and all(not character_slot_policy.slot_expression_enabled(slot) for slot in matching_slots):
|
||||
return None, "character_slots:disabled"
|
||||
return fallback, "input"
|
||||
|
||||
|
||||
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 _choose_text(rng: random.Random, items: list[Any]) -> str:
|
||||
return _entry_text(_weighted_choice(rng, items))
|
||||
|
||||
|
||||
def character_expression_entries(
|
||||
rng: random.Random,
|
||||
expression_pool: list[Any],
|
||||
fallback_intensity: float,
|
||||
label_map: dict[str, dict[str, Any]],
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
expression_phase: str = "",
|
||||
) -> list[str]:
|
||||
labels = [
|
||||
*[f"Woman {chr(ord('A') + index)}" for index in range(max(0, women_count))],
|
||||
*[f"Man {chr(ord('A') + index)}" for index in range(max(0, men_count))],
|
||||
]
|
||||
expressions: list[str] = []
|
||||
used: set[str] = set()
|
||||
for label in labels:
|
||||
slot = label_map.get(label)
|
||||
if not slot:
|
||||
continue
|
||||
if pov_policy.slot_is_pov(slot):
|
||||
continue
|
||||
if not character_slot_policy.slot_expression_enabled(slot):
|
||||
continue
|
||||
intensity = character_slot_policy.slot_expression_intensity_for_phase(slot, expression_phase)
|
||||
if intensity is None:
|
||||
intensity = fallback_intensity
|
||||
entries = category_policy.compatible_entries(
|
||||
expression_entries_for_intensity(expression_pool, intensity),
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
if not entries:
|
||||
continue
|
||||
choice = ""
|
||||
for _attempt in range(5):
|
||||
candidate = _choose_text(rng, entries)
|
||||
if candidate not in used:
|
||||
choice = candidate
|
||||
break
|
||||
if not choice:
|
||||
choice = _choose_text(rng, entries)
|
||||
used.add(choice)
|
||||
expressions.append(f"{label} has {choice}")
|
||||
return expressions
|
||||
|
||||
|
||||
def sanitize_character_expression_text_for_action(
|
||||
expression_text: str,
|
||||
role_graph: Any,
|
||||
item: Any,
|
||||
axis_values: Any = None,
|
||||
) -> str:
|
||||
text = str(expression_text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
context = " ".join(
|
||||
str(part or "").lower()
|
||||
for part in (
|
||||
role_graph,
|
||||
item,
|
||||
*((axis_values or {}).values() if isinstance(axis_values, dict) else ()),
|
||||
)
|
||||
)
|
||||
woman_active_outercourse = (
|
||||
re.search(r"\bwoman [a-z]\b", context)
|
||||
and re.search(r"\bman [a-z]\b", context)
|
||||
and any(
|
||||
term in context
|
||||
for term in (
|
||||
"boobjob",
|
||||
"titjob",
|
||||
"breast sex",
|
||||
"breasts tightly",
|
||||
"testicle",
|
||||
"balls-licking",
|
||||
"balls licking",
|
||||
"penis-licking",
|
||||
"penis licking",
|
||||
"handjob",
|
||||
"hand job",
|
||||
"footjob",
|
||||
)
|
||||
)
|
||||
)
|
||||
woman_gives_oral = (
|
||||
re.search(r"\bwoman [a-z]\b", context)
|
||||
and re.search(r"\bman [a-z]\b", context)
|
||||
and any(
|
||||
term in context
|
||||
for term in (
|
||||
"takes man",
|
||||
"penis in her mouth",
|
||||
"mouth at penis level",
|
||||
"fellatio",
|
||||
"blowjob",
|
||||
"deepthroat",
|
||||
"penis sucking",
|
||||
"lips wrapped",
|
||||
)
|
||||
)
|
||||
)
|
||||
man_gives_oral = (
|
||||
re.search(r"\bwoman [a-z]\b", context)
|
||||
and re.search(r"\bman [a-z]\b", context)
|
||||
and any(
|
||||
term in context
|
||||
for term in (
|
||||
"mouth on her pussy",
|
||||
"mouth on woman",
|
||||
"mouth pressed to her pussy",
|
||||
"cunnilingus",
|
||||
"pussy licking",
|
||||
"tongue on pussy",
|
||||
)
|
||||
)
|
||||
)
|
||||
mouth_expression_terms = ("mouth", "oral", "tongue", "lips", "gagging", "saliva")
|
||||
clauses = [clause.strip() for clause in text.split(";") if clause.strip()]
|
||||
if woman_active_outercourse:
|
||||
clauses = [clause for clause in clauses if not re.match(r"^Man [A-Z] has\b", clause)]
|
||||
if woman_gives_oral:
|
||||
clauses = [
|
||||
clause
|
||||
for clause in clauses
|
||||
if not (
|
||||
re.match(r"^Man [A-Z] has\b", clause)
|
||||
and any(term in clause.lower() for term in mouth_expression_terms)
|
||||
)
|
||||
]
|
||||
if man_gives_oral:
|
||||
clauses = [
|
||||
clause
|
||||
for clause in clauses
|
||||
if not (
|
||||
re.match(r"^Woman [A-Z] has\b", clause)
|
||||
and any(term in clause.lower() for term in mouth_expression_terms)
|
||||
)
|
||||
]
|
||||
return "; ".join(clauses)
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import generate_prompt_batches as g
|
||||
from . import row_item as row_item_policy
|
||||
from . import seed_config as seed_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import category_library as category_policy
|
||||
import generate_prompt_batches as g
|
||||
import row_item as row_item_policy
|
||||
import seed_config as seed_policy
|
||||
|
||||
|
||||
def ratio_or_none(value: float) -> float | None:
|
||||
try:
|
||||
ratio = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if ratio < 0:
|
||||
return None
|
||||
return max(0.0, min(1.0, ratio))
|
||||
|
||||
|
||||
def clamped_float(value: Any, default: float = 0.5, min_value: float = 0.0, max_value: float = 1.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(min_value, min(max_value, number))
|
||||
|
||||
|
||||
def pick_clothing_mode(rng: random.Random, clothing: str, minimal_ratio: float | None) -> str:
|
||||
if clothing == "random":
|
||||
return "minimal" if rng.random() < 0.5 else "full"
|
||||
if minimal_ratio is None:
|
||||
return clothing
|
||||
return "minimal" if rng.random() < minimal_ratio else "full"
|
||||
|
||||
|
||||
def pick_pose_mode(rng: random.Random, poses: str, standard_ratio: float | None) -> str:
|
||||
if poses == "random":
|
||||
return "standard" if rng.random() < 0.5 else "evocative"
|
||||
if standard_ratio is None:
|
||||
return poses
|
||||
return "standard" if rng.random() < standard_ratio else "evocative"
|
||||
|
||||
|
||||
def pick_figure_bias(rng: random.Random, figure: str) -> str:
|
||||
if figure in ("curvy", "balanced", "bombshell"):
|
||||
return figure
|
||||
return g.choose(rng, ["curvy", "balanced", "bombshell"])
|
||||
|
||||
|
||||
def pick_expression_intensity(rng: random.Random, expression_intensity: Any) -> tuple[float, str]:
|
||||
try:
|
||||
value = float(expression_intensity)
|
||||
except (TypeError, ValueError):
|
||||
return 0.5, "default"
|
||||
if value < 0:
|
||||
return round(rng.random(), 2), "random"
|
||||
return clamped_float(value, 0.5), "input"
|
||||
|
||||
|
||||
def build_auto_weighted_row(
|
||||
row_number: int,
|
||||
start_index: int,
|
||||
clothing: str,
|
||||
ethnicity: str,
|
||||
poses: str,
|
||||
backside_bias: float,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
minimal_clothing_ratio: float | None,
|
||||
standard_pose_ratio: float | None,
|
||||
seed: int,
|
||||
) -> dict[str, Any]:
|
||||
batch_number = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1)
|
||||
rows = g.build_rows(
|
||||
batch_number * g.BATCH_SIZE,
|
||||
start_index,
|
||||
clothing,
|
||||
ethnicity,
|
||||
poses,
|
||||
backside_bias,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
minimal_clothing_ratio,
|
||||
standard_pose_ratio,
|
||||
seed,
|
||||
g.EXPRESSION_SEED + seed,
|
||||
)
|
||||
row = rows[row_number - 1]
|
||||
row["main_category"] = "auto_weighted"
|
||||
row["subcategory"] = row.get("primary_subject", "auto")
|
||||
row["source"] = "built_in_generator"
|
||||
return row
|
||||
|
||||
|
||||
def build_direct_builtin_row(
|
||||
category: str,
|
||||
row_number: int,
|
||||
start_index: int,
|
||||
clothing: str,
|
||||
ethnicity: str,
|
||||
poses: str,
|
||||
backside_bias: float,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
minimal_clothing_ratio: float | None,
|
||||
standard_pose_ratio: float | None,
|
||||
seed: int,
|
||||
) -> dict[str, Any]:
|
||||
rng = random.Random(seed_policy.row_seed(seed, row_number))
|
||||
expr_deck = g.ExpressionDeck(
|
||||
g.EXPRESSIONS,
|
||||
random.Random(seed_policy.row_seed(g.EXPRESSION_SEED + seed, row_number)),
|
||||
)
|
||||
batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1)
|
||||
index = start_index + row_number - 1
|
||||
row_clothing = pick_clothing_mode(rng, clothing, minimal_clothing_ratio)
|
||||
row_poses = pick_pose_mode(rng, poses, standard_pose_ratio)
|
||||
|
||||
if category == "woman":
|
||||
row = g.make_single(
|
||||
index,
|
||||
batch,
|
||||
rng,
|
||||
"woman",
|
||||
expr_deck,
|
||||
row_clothing,
|
||||
ethnicity,
|
||||
row_poses,
|
||||
backside_bias,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
)
|
||||
elif category == "man":
|
||||
row = g.make_single(index, batch, rng, "man", expr_deck, row_clothing, ethnicity, row_poses, backside_bias, figure)
|
||||
elif category == "couple":
|
||||
row = g.make_couple(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women)
|
||||
elif category == "group_or_layout":
|
||||
row = g.make_group_or_layout(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women)
|
||||
else:
|
||||
raise ValueError(f"Unknown built-in category: {category}")
|
||||
|
||||
row["main_category"] = category
|
||||
row["subcategory"] = row.get("pose_mode", category)
|
||||
row["source"] = "built_in_generator"
|
||||
return row
|
||||
|
||||
|
||||
def auto_full_choice(seed_config: dict[str, int], seed: int, row_number: int) -> str:
|
||||
categories = category_policy.load_category_library()
|
||||
if not categories:
|
||||
return "auto_weighted"
|
||||
category_rng = seed_policy.axis_rng(seed_config, "category", seed, row_number)
|
||||
choices: list[dict[str, Any]] = [{"category": "auto_weighted", "weight": 1.0}]
|
||||
choices.extend(
|
||||
{
|
||||
"category": category["name"],
|
||||
"weight": category.get("weight", 1.0),
|
||||
}
|
||||
for category in categories
|
||||
)
|
||||
choice = row_item_policy.weighted_choice(category_rng, choices)
|
||||
return str(choice.get("category") or "auto_weighted")
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from string import Formatter
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import category_template_metadata as template_policy
|
||||
from . import generate_prompt_batches as g
|
||||
from . import outercourse_action_policy as outercourse_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import category_library as category_policy
|
||||
import category_template_metadata as template_policy
|
||||
import generate_prompt_batches as g
|
||||
import outercourse_action_policy as outercourse_policy
|
||||
|
||||
|
||||
class SafeFormatDict(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def slug(value: str) -> str:
|
||||
return g.slugify(value) or "custom"
|
||||
|
||||
|
||||
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()
|
||||
pair_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 pair_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:
|
||||
return category_policy._entry_text(item)
|
||||
|
||||
|
||||
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 choose_text(rng: random.Random, items: list[Any]) -> str:
|
||||
return item_text(weighted_choice(rng, items))
|
||||
|
||||
|
||||
def choose_distinct_text(rng: random.Random, items: list[Any], first_text: str) -> str:
|
||||
first_text = item_text(first_text).lower()
|
||||
distinct = [item for item in items if item_text(item).lower() != first_text]
|
||||
if not distinct:
|
||||
return ""
|
||||
return choose_text(rng, distinct)
|
||||
|
||||
|
||||
def choose_pair(rng: random.Random, items: list[Any]) -> tuple[str, str]:
|
||||
return pair_from(weighted_choice(rng, items))
|
||||
|
||||
|
||||
def oral_acts_for_position(values: list[Any], position: str) -> list[Any]:
|
||||
position_text = str(position or "").lower()
|
||||
if not position_text:
|
||||
return values
|
||||
|
||||
def act_text(value: Any) -> str:
|
||||
return entry_text(value).lower()
|
||||
|
||||
def filtered(predicate: Callable[[str], bool]) -> list[Any]:
|
||||
matches = [value for value in values if predicate(act_text(value))]
|
||||
return matches or values
|
||||
|
||||
penis_terms = ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth")
|
||||
cunnilingus_terms = ("cunnilingus", "pussy licking", "tongue on pussy", "oral sex with tongue and fingers", "mouth on genitals")
|
||||
if "sixty-nine" in position_text:
|
||||
return filtered(lambda text: "sixty-nine" in text)
|
||||
if "face-sitting" in position_text:
|
||||
return filtered(lambda text: "face-sitting" in text or any(term in text for term in cunnilingus_terms))
|
||||
if "kneeling oral" in position_text:
|
||||
return filtered(lambda text: any(term in text for term in penis_terms))
|
||||
if "straddled oral" in position_text or "reclining cunnilingus" in position_text:
|
||||
return filtered(lambda text: "sixty-nine" not in text and not any(term in text for term in penis_terms))
|
||||
if "spread-leg oral" in position_text:
|
||||
return filtered(lambda text: "sixty-nine" not in text and "face-sitting" not in text)
|
||||
if any(term in position_text for term in ("standing oral", "kneeling oral", "edge-of-bed oral", "chair oral", "side-lying oral")):
|
||||
return filtered(lambda text: "sixty-nine" not in text and "face-sitting" not in text)
|
||||
return values
|
||||
|
||||
|
||||
def oral_axis_values_for_context(values: list[Any], position: str, oral_act: str, axis_name: str) -> list[Any]:
|
||||
axis_name = str(axis_name or "").lower()
|
||||
if axis_name not in {"body_contact", "hand_detail", "mouth_detail", "saliva_detail", "climax_hint", "visibility"}:
|
||||
return values
|
||||
position_text = str(position or "").lower()
|
||||
act_text = str(oral_act or "").lower()
|
||||
woman_gives = any(
|
||||
term in act_text
|
||||
for term in ("fellatio", "blowjob", "deepthroat", "penis sucking", "penis in mouth")
|
||||
)
|
||||
man_gives = any(
|
||||
term in act_text
|
||||
for term in ("cunnilingus", "pussy licking", "tongue on pussy")
|
||||
)
|
||||
if not (woman_gives or man_gives):
|
||||
return values
|
||||
|
||||
def value_text(value: Any) -> str:
|
||||
return entry_text(value).lower()
|
||||
|
||||
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
|
||||
matches = [
|
||||
value
|
||||
for value in values
|
||||
if any(term in value_text(value) for term in terms)
|
||||
and not any(term in value_text(value) for term in excluded_terms)
|
||||
]
|
||||
return matches or values
|
||||
|
||||
if woman_gives:
|
||||
by_axis = {
|
||||
"body_contact": ("hips pushed", "fingers tangled", "bodies stacked", "hands on thighs"),
|
||||
"hand_detail": ("hips", "penis", "head", "hair"),
|
||||
"mouth_detail": ("lips", "mouth", "deep mouth", "saliva"),
|
||||
"saliva_detail": ("saliva", "wet lips", "slick wet mouth", "drool", "mouth"),
|
||||
"climax_hint": ("mouth", "lips", "tongue", "breasts", "belly", "sexual fluids"),
|
||||
"visibility": ("mouth", "penis", "oral"),
|
||||
}
|
||||
excluded = {
|
||||
"body_contact": ("legs held open", "spread legs", "ass lifted", "chest pressed to thighs"),
|
||||
"hand_detail": ("spreading thighs", "sheets", "cupping breasts", "pressing into thighs", "holding the ass"),
|
||||
}
|
||||
return filtered(by_axis.get(axis_name, ("mouth", "penis")), excluded.get(axis_name, ()))
|
||||
if man_gives and ("kneeling oral" in position_text or "standing oral" in position_text):
|
||||
by_axis = {
|
||||
"body_contact": ("legs held open", "one body kneeling", "chest pressed", "ass lifted", "hands on thighs"),
|
||||
"hand_detail": ("thigh", "hips", "head", "ass"),
|
||||
"mouth_detail": ("tongue", "wet lips", "deep mouth", "genitals"),
|
||||
"saliva_detail": ("saliva", "wet lips", "tongue", "drool"),
|
||||
"climax_hint": ("sexual fluids", "orgasmic tension"),
|
||||
"visibility": ("mouth", "pussy", "oral", "genital"),
|
||||
}
|
||||
return filtered(by_axis.get(axis_name, ("mouth", "pussy", "tongue")), ("penis", "breasts"))
|
||||
return values
|
||||
|
||||
|
||||
def outercourse_acts_for_position(values: list[Any], position: str) -> list[Any]:
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(position)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_GENERIC:
|
||||
return values
|
||||
|
||||
def act_text(value: Any) -> str:
|
||||
return entry_text(value).lower()
|
||||
|
||||
def filtered(predicate: Callable[[str], bool]) -> list[Any]:
|
||||
matches = [value for value in values if predicate(act_text(value))]
|
||||
return matches or values
|
||||
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB:
|
||||
return filtered(lambda text: any(term in text for term in ("boobjob", "titjob", "breast sex", "breasts")))
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE:
|
||||
return filtered(lambda text: any(term in text for term in ("testicle", "balls")))
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING:
|
||||
return filtered(lambda text: "licking" in text or "tongue" in text)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB:
|
||||
return filtered(lambda text: any(term in text for term in ("handjob", "hand job", "hand wrapped", "two-handed")))
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB:
|
||||
return filtered(lambda text: any(term in text for term in ("footjob", "feet", "soles", "toes")))
|
||||
return values
|
||||
|
||||
|
||||
def outercourse_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]:
|
||||
action_kind = outercourse_policy.infer_outercourse_action_kind(position)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_GENERIC:
|
||||
return values
|
||||
axis_name = str(axis_name or "").lower()
|
||||
if axis_name not in {"contact_detail", "hand_detail", "texture_detail", "visibility", "body_contact"}:
|
||||
return values
|
||||
|
||||
def value_text(value: Any) -> str:
|
||||
return entry_text(value).lower()
|
||||
|
||||
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
|
||||
matches = [
|
||||
value
|
||||
for value in values
|
||||
if any(term in value_text(value) for term in terms)
|
||||
and not any(term in value_text(value) for term in excluded_terms)
|
||||
]
|
||||
if matches:
|
||||
return matches
|
||||
if excluded_terms:
|
||||
non_excluded = [
|
||||
value
|
||||
for value in values
|
||||
if not any(term in value_text(value) for term in excluded_terms)
|
||||
]
|
||||
if non_excluded:
|
||||
return non_excluded
|
||||
return values
|
||||
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_BOOBJOB:
|
||||
by_axis = {
|
||||
"contact_detail": ("compressed", "glans", "breast", "breasts", "soft tissue", "skin visibly"),
|
||||
"hand_detail": ("breast", "breasts", "fingers"),
|
||||
"texture_detail": ("compression", "soft flesh", "skin", "flesh", "asymmetry"),
|
||||
"visibility": ("breast", "breasts", "glans", "shaft"),
|
||||
"body_contact": ("torso", "body angle", "body angled", "shoulders", "hips"),
|
||||
}
|
||||
excluded_by_axis = {
|
||||
"contact_detail": ("hand wrapped", "fingers and palm", "soles", "toes", "balls", "tongue"),
|
||||
"hand_detail": ("base of the penis", "penis shaft", "balls", "thigh", "ankles", "stroking"),
|
||||
"texture_detail": ("toes", "soles", "tongue"),
|
||||
"visibility": ("balls", "soles", "toes", "hand"),
|
||||
"body_contact": ("head tucked", "face directly", "base of the penis"),
|
||||
}
|
||||
return filtered(
|
||||
by_axis.get(axis_name, ("breast", "breasts", "shaft")),
|
||||
excluded_by_axis.get(axis_name, ()),
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_TESTICLE:
|
||||
by_axis = {
|
||||
"contact_detail": ("balls", "lips", "tongue", "wet"),
|
||||
"hand_detail": ("balls", "base", "thigh"),
|
||||
"texture_detail": ("wet", "saliva", "skin"),
|
||||
"visibility": ("balls", "mouth"),
|
||||
"body_contact": ("torso", "shoulders", "head tucked", "base of the penis", "knees", "thigh"),
|
||||
}
|
||||
return filtered(by_axis.get(axis_name, ("balls", "mouth", "tongue")))
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_PENIS_LICKING:
|
||||
by_axis = {
|
||||
"contact_detail": ("tongue", "lips", "glans", "shaft", "wet"),
|
||||
"hand_detail": ("base", "penis", "thigh"),
|
||||
"texture_detail": ("wet", "saliva", "skin"),
|
||||
"visibility": ("tongue", "penis"),
|
||||
"body_contact": ("head low", "face directly", "torso", "pelvis", "base of the penis", "hips", "body angled"),
|
||||
}
|
||||
return filtered(by_axis.get(axis_name, ("tongue", "glans", "shaft")))
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_HANDJOB:
|
||||
by_axis = {
|
||||
"contact_detail": ("hand", "fingers", "palm", "shaft", "glans"),
|
||||
"hand_detail": ("hand", "hands", "shaft", "penis"),
|
||||
"texture_detail": ("fingers", "pressure", "skin", "shaft"),
|
||||
"visibility": ("hand", "penis", "shaft", "glans"),
|
||||
"body_contact": ("hips", "knees", "body angle"),
|
||||
}
|
||||
excluded_by_axis = {
|
||||
"contact_detail": ("balls", "soles", "toes", "breast", "breasts", "tongue"),
|
||||
"hand_detail": ("balls", "thigh", "ankles", "breast", "breasts"),
|
||||
"texture_detail": ("toes", "soles", "tongue", "breast", "breasts"),
|
||||
"visibility": ("balls", "feet", "soles", "breast", "mouth"),
|
||||
}
|
||||
return filtered(
|
||||
by_axis.get(axis_name, ("hand", "penis", "shaft")),
|
||||
excluded_by_axis.get(axis_name, ()),
|
||||
)
|
||||
if action_kind == outercourse_policy.OUTERCOURSE_FOOTJOB:
|
||||
by_axis = {
|
||||
"contact_detail": ("soles", "toes"),
|
||||
"hand_detail": ("ankles", "thighs"),
|
||||
"texture_detail": ("toes", "soles", "pressure"),
|
||||
"visibility": ("feet", "soles"),
|
||||
"body_contact": ("legs", "knees", "body angled"),
|
||||
}
|
||||
excluded_by_axis = {
|
||||
"contact_detail": ("hand", "finger", "palm", "balls", "tongue", "breast"),
|
||||
"texture_detail": ("fingers", "tongue", "breast"),
|
||||
"visibility": ("hand", "balls", "breast"),
|
||||
}
|
||||
return filtered(
|
||||
by_axis.get(axis_name, ("feet", "soles", "toes")),
|
||||
excluded_by_axis.get(axis_name, ()),
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def anal_axis_values_for_position(values: list[Any], position: str, axis_name: str) -> list[Any]:
|
||||
position_text = str(position or "").lower()
|
||||
if not position_text:
|
||||
return values
|
||||
axis_name = str(axis_name or "").lower()
|
||||
if axis_name not in {"body_contact", "hand_detail", "leg_detail", "thrust_detail", "visibility"}:
|
||||
return values
|
||||
|
||||
def value_text(value: Any) -> str:
|
||||
return entry_text(value).lower()
|
||||
|
||||
def filtered(terms: tuple[str, ...], excluded_terms: tuple[str, ...] = ()) -> list[Any]:
|
||||
matches = [
|
||||
value
|
||||
for value in values
|
||||
if any(term in value_text(value) for term in terms)
|
||||
and not any(term in value_text(value) for term in excluded_terms)
|
||||
]
|
||||
if matches:
|
||||
return matches
|
||||
if excluded_terms:
|
||||
non_excluded = [
|
||||
value
|
||||
for value in values
|
||||
if not any(term in value_text(value) for term in excluded_terms)
|
||||
]
|
||||
if non_excluded:
|
||||
return non_excluded
|
||||
return values
|
||||
|
||||
if "side-lying" in position_text or "spooning" in position_text:
|
||||
by_axis = {
|
||||
"body_contact": ("bodies locked", "chests pressed", "sweaty", "hips pressed"),
|
||||
"hand_detail": ("hips", "waist", "cheeks", "shoulders"),
|
||||
"leg_detail": ("one leg lifted", "thighs held open", "legs spread"),
|
||||
"thrust_detail": ("pelvis pressed", "bodies rocking", "wet skin", "hard grinding"),
|
||||
"visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"),
|
||||
}
|
||||
return filtered(
|
||||
by_axis.get(axis_name, ("side", "thigh", "hips")),
|
||||
("standing", "kneeling", "draped over shoulders", "knees pressed to chest"),
|
||||
)
|
||||
if "standing" in position_text:
|
||||
by_axis = {
|
||||
"body_contact": ("hips pressed", "bodies locked", "one body bent over", "ass lifted", "sweaty"),
|
||||
"hand_detail": ("hips", "waist", "cheeks", "shoulders"),
|
||||
"leg_detail": ("standing", "one foot planted"),
|
||||
"thrust_detail": ("hips", "pelvis", "hard grinding", "bodies rocking"),
|
||||
"visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"),
|
||||
}
|
||||
return filtered(
|
||||
by_axis.get(axis_name, ("standing", "hips")),
|
||||
("kneeling", "draped over shoulders", "knees pressed to chest", "side-lying"),
|
||||
)
|
||||
if "edge-of-bed" in position_text or "bed-edge" in position_text or "edge supported" in position_text:
|
||||
by_axis = {
|
||||
"body_contact": ("thighs held open", "hips pressed", "bodies locked", "ass lifted"),
|
||||
"hand_detail": ("hips", "waist", "cheeks", "thighs"),
|
||||
"leg_detail": ("knees pressed", "legs draped", "thighs held open", "one foot planted"),
|
||||
"thrust_detail": ("hips", "pelvis", "hard grinding", "bodies rocking"),
|
||||
"visibility": ("ass and penis", "anal penetration", "open thighs", "genital contact"),
|
||||
}
|
||||
return filtered(by_axis.get(axis_name, ("thigh", "hips")), ("standing", "side-lying"))
|
||||
if "kneeling" in position_text:
|
||||
by_axis = {
|
||||
"body_contact": ("ass lifted", "hips pressed", "bodies locked", "one body bent over"),
|
||||
"hand_detail": ("hips", "waist", "cheeks", "thighs"),
|
||||
"leg_detail": ("kneeling", "thighs held open", "legs spread"),
|
||||
"thrust_detail": ("hips", "pelvis", "ass pushed", "hard grinding"),
|
||||
"visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"),
|
||||
}
|
||||
return filtered(
|
||||
by_axis.get(axis_name, ("kneeling", "hips")),
|
||||
("standing", "draped over shoulders", "knees pressed to chest", "side-lying"),
|
||||
)
|
||||
if "doggy" in position_text or "face-down" in position_text or "bent-over" in position_text:
|
||||
by_axis = {
|
||||
"body_contact": ("ass lifted", "one body bent over", "hips pressed", "bodies locked"),
|
||||
"hand_detail": ("hips", "waist", "cheeks", "thighs"),
|
||||
"leg_detail": ("legs spread", "kneeling", "one foot planted", "standing"),
|
||||
"thrust_detail": ("ass pushed", "hips", "pelvis", "hard grinding"),
|
||||
"visibility": ("ass and penis", "anal penetration", "spread cheeks", "genital contact"),
|
||||
}
|
||||
excluded = ("side-lying", "draped over shoulders", "knees pressed to chest")
|
||||
if "face-down" in position_text or "doggy" in position_text:
|
||||
excluded = (*excluded, "standing")
|
||||
return filtered(by_axis.get(axis_name, ("ass", "hips")), excluded)
|
||||
return values
|
||||
|
||||
|
||||
def _format(template: str, context: dict[str, Any]) -> str:
|
||||
fields = {key for _, key, _, _ in Formatter().parse(template) if key}
|
||||
safe_context = SafeFormatDict({key: "" for key in fields})
|
||||
safe_context.update(context)
|
||||
return template.format_map(safe_context)
|
||||
|
||||
|
||||
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], dict[str, Any]]:
|
||||
templates = category_policy.template_list(category, subcategory, item, "item_templates")
|
||||
axes = category_policy.merged_axes(category, subcategory, item)
|
||||
inherited_metadata = template_policy.inherited_template_metadata(category, subcategory, item)
|
||||
if templates and axes:
|
||||
template_entry = weighted_choice(rng, category_policy.compatible_entries(templates, women_count, men_count))
|
||||
template = entry_text(template_entry)
|
||||
fields = [key for _, key, _, _ in Formatter().parse(template) if key]
|
||||
unique_fields = list(dict.fromkeys(fields))
|
||||
axis_values: dict[str, str] = {}
|
||||
subcategory_slug = str(subcategory.get("slug") or "").lower()
|
||||
if subcategory_slug in ("oral_sex", "outercourse_sex", "anal_double_penetration") and "position" in unique_fields and axes.get("position"):
|
||||
position_values = category_policy.compatible_entries(axes["position"], women_count, men_count)
|
||||
axis_values["position"] = entry_text(weighted_choice(rng, position_values))
|
||||
for name in unique_fields:
|
||||
if name in axis_values or name not in axes or not axes[name]:
|
||||
continue
|
||||
values = category_policy.compatible_entries(axes[name], women_count, men_count)
|
||||
if subcategory_slug == "oral_sex" and name == "oral_act":
|
||||
values = oral_acts_for_position(values, axis_values.get("position", ""))
|
||||
elif subcategory_slug == "oral_sex":
|
||||
values = oral_axis_values_for_context(
|
||||
values,
|
||||
axis_values.get("position", ""),
|
||||
axis_values.get("oral_act", ""),
|
||||
name,
|
||||
)
|
||||
if subcategory_slug == "outercourse_sex" and name == "outer_act":
|
||||
values = outercourse_acts_for_position(values, axis_values.get("position", ""))
|
||||
if subcategory_slug == "outercourse_sex":
|
||||
values = outercourse_axis_values_for_position(values, axis_values.get("position", ""), name)
|
||||
if subcategory_slug == "anal_double_penetration":
|
||||
values = anal_axis_values_for_position(values, axis_values.get("position", ""), name)
|
||||
axis_values[name] = entry_text(weighted_choice(rng, values))
|
||||
item_prompt = _format(template, axis_values).strip()
|
||||
name = item_name(item) or subcategory["name"]
|
||||
return (
|
||||
item_prompt,
|
||||
name,
|
||||
axis_values,
|
||||
template_policy.merge_template_metadata(inherited_metadata, template_policy.template_metadata(template_entry)),
|
||||
)
|
||||
return item_text(item), item_name(item), {}, template_policy.merge_template_metadata(
|
||||
inherited_metadata,
|
||||
template_policy.template_metadata(item),
|
||||
)
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import generate_prompt_batches as g
|
||||
from . import location_config as location_policy
|
||||
from . import row_camera
|
||||
from . import seed_config as seed_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import generate_prompt_batches as g
|
||||
import location_config as location_policy
|
||||
import row_camera
|
||||
import seed_config as seed_policy
|
||||
|
||||
|
||||
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 g.slugify(str(value.get("name") or text)) or "custom").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 g.slugify(text) or "custom", 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 _choose_pair(rng: random.Random, items: list[Any]) -> tuple[str, str]:
|
||||
return _pair_from(_weighted_choice(rng, items))
|
||||
|
||||
|
||||
def _metadata_entry(value: Any, *, slug: str = "", text: str = "") -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
entry = dict(value)
|
||||
elif isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
entry = {"slug": str(value[0]), "prompt": str(value[1])}
|
||||
else:
|
||||
entry = {"prompt": str(value or "")}
|
||||
if slug:
|
||||
entry["slug"] = slug
|
||||
if text:
|
||||
if "prompt" in entry:
|
||||
entry["prompt"] = text
|
||||
elif "text" in entry:
|
||||
entry["text"] = text
|
||||
else:
|
||||
entry["prompt"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def _choose_text(rng: random.Random, items: list[Any]) -> str:
|
||||
item = _weighted_choice(rng, items)
|
||||
return _text_from_entry(item)
|
||||
|
||||
|
||||
def _text_from_entry(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 legacy_scene_entries_for_row(row: dict[str, Any]) -> list[Any]:
|
||||
subject = str(row.get("primary_subject") or "").lower()
|
||||
if "group" in subject or "layout" in subject:
|
||||
return list(g.GROUP_SCENES)
|
||||
return list(g.SCENES)
|
||||
|
||||
|
||||
def legacy_scene_text_for_slug(slug: str) -> str:
|
||||
for entry in list(g.SCENES) + list(g.GROUP_SCENES):
|
||||
entry_slug, entry_text = _pair_from(entry)
|
||||
if entry_slug == slug:
|
||||
return entry_text
|
||||
return ""
|
||||
|
||||
|
||||
def apply_location_config_to_legacy_row(
|
||||
row: dict[str, Any],
|
||||
location_config: dict[str, Any],
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
) -> dict[str, Any]:
|
||||
if not location_policy.location_config_active(location_config):
|
||||
return row
|
||||
location_entries = _list_from(location_config.get("scene_entries"))
|
||||
if location_config.get("apply_mode") == "add":
|
||||
choices = legacy_scene_entries_for_row(row)
|
||||
_unique_extend(choices, location_entries)
|
||||
else:
|
||||
choices = location_entries
|
||||
scene_rng = seed_policy.axis_rng(seed_config, "scene", seed, row_number)
|
||||
scene_choice = _weighted_choice(scene_rng, choices)
|
||||
scene_slug, scene_text = _pair_from(scene_choice)
|
||||
scene_entry = _metadata_entry(scene_choice, slug=scene_slug, text=scene_text)
|
||||
old_slug = str(row.get("scene") or "")
|
||||
old_text = legacy_scene_text_for_slug(old_slug)
|
||||
row["source_scene"] = old_slug
|
||||
row["source_scene_text"] = old_text
|
||||
row["scene"] = scene_slug
|
||||
row["scene_text"] = scene_text
|
||||
row["scene_entry"] = scene_entry
|
||||
row["location_theme"] = str(location_config.get("theme") or "")
|
||||
row["scene_theme"] = scene_entry.get("theme", "") or (
|
||||
str(location_config.get("theme") or "")
|
||||
if location_config.get("apply_mode") == "replace"
|
||||
else ""
|
||||
)
|
||||
row["location_config"] = location_config
|
||||
if old_text:
|
||||
row["prompt"] = str(row.get("prompt") or "").replace(f"Scene: {old_text}.", f"Scene: {scene_text}.")
|
||||
row["caption"] = str(row.get("caption") or "").replace(f", {old_text},", f", {scene_text},")
|
||||
else:
|
||||
row["prompt"] = re.sub(
|
||||
r"Scene:\s*.*?\.\s*Pose:",
|
||||
f"Scene: {scene_text}. Pose:",
|
||||
str(row.get("prompt") or ""),
|
||||
count=1,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def legacy_composition_entries_for_row(row: dict[str, Any]) -> list[Any]:
|
||||
subject = str(row.get("primary_subject") or "").lower()
|
||||
if "group" in subject or "layout" in subject:
|
||||
return list(g.GROUP_COMPOSITIONS)
|
||||
return list(g.COMPOSITIONS)
|
||||
|
||||
|
||||
def apply_composition_config_to_legacy_row(
|
||||
row: dict[str, Any],
|
||||
composition_config: dict[str, Any],
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
) -> dict[str, Any]:
|
||||
if not location_policy.composition_config_active(composition_config):
|
||||
return row
|
||||
composition_entries = _list_from(composition_config.get("composition_entries"))
|
||||
if composition_config.get("apply_mode") == "add":
|
||||
choices = legacy_composition_entries_for_row(row)
|
||||
_unique_extend(choices, composition_entries)
|
||||
else:
|
||||
choices = composition_entries
|
||||
composition_rng = seed_policy.axis_rng(seed_config, "composition", seed, row_number)
|
||||
composition_choice = _weighted_choice(composition_rng, choices)
|
||||
new_composition = _text_from_entry(composition_choice)
|
||||
composition_entry = _metadata_entry(composition_choice, text=new_composition)
|
||||
old_composition = str(row.get("composition") or "")
|
||||
old_prompt_fragment = f"Composition: vertical {old_composition}."
|
||||
new_prompt_fragment = f"Composition: {row_camera.composition_prompt(new_composition)}."
|
||||
row["source_composition"] = old_composition
|
||||
row["composition"] = new_composition
|
||||
row["composition_entry"] = composition_entry
|
||||
row["composition_theme"] = str(composition_config.get("theme") or "")
|
||||
row["composition_prompt"] = row_camera.composition_prompt(new_composition)
|
||||
row["composition_config"] = composition_config
|
||||
if old_composition:
|
||||
row["prompt"] = str(row.get("prompt") or "").replace(old_prompt_fragment, new_prompt_fragment)
|
||||
row["caption"] = str(row.get("caption") or "").replace(f", {old_composition},", f", {new_composition},")
|
||||
else:
|
||||
row["prompt"] = re.sub(
|
||||
r"Composition:\s*.*?\.\s*Use",
|
||||
f"{new_prompt_fragment} Use",
|
||||
str(row.get("prompt") or ""),
|
||||
count=1,
|
||||
)
|
||||
return row
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import generate_prompt_batches as prompt_batches
|
||||
from . import row_location as row_location_policy
|
||||
from .prompt_hygiene import combine_negative_text, sanitize_caption_text, sanitize_negative_text, sanitize_prompt_text
|
||||
except ImportError: # Allows local smoke tests with `python tools/prompt_smoke.py`.
|
||||
import generate_prompt_batches as prompt_batches
|
||||
import row_location as row_location_policy
|
||||
from prompt_hygiene import combine_negative_text, sanitize_caption_text, sanitize_negative_text, sanitize_prompt_text
|
||||
|
||||
|
||||
def _trigger_tuple(active_trigger: str) -> tuple[str, ...]:
|
||||
trigger = str(active_trigger or "").strip()
|
||||
return (trigger,) if trigger else ()
|
||||
|
||||
|
||||
def prepend_trigger(prompt: str, trigger: str, enabled: bool) -> str:
|
||||
trigger = str(trigger or "").strip()
|
||||
prompt = str(prompt or "")
|
||||
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:
|
||||
return combine_negative_text(base, extra)
|
||||
|
||||
|
||||
def caption_from_parts(parts: list[Any] | tuple[Any, ...], *, active_trigger: str = "") -> str:
|
||||
text = ", ".join(str(part).strip() for part in parts if str(part).strip())
|
||||
return sanitize_caption_text(text, triggers=_trigger_tuple(active_trigger))
|
||||
|
||||
|
||||
def _setdefault_nonempty(row: dict[str, Any], key: str, value: Any) -> None:
|
||||
if str(row.get(key) or "").strip():
|
||||
return
|
||||
if str(value or "").strip():
|
||||
row[key] = value
|
||||
|
||||
|
||||
def _setdefault_count(row: dict[str, Any], key: str, value: int) -> None:
|
||||
if str(row.get(key) or "").strip():
|
||||
return
|
||||
row[key] = int(value)
|
||||
|
||||
|
||||
def _legacy_subject_metadata(row: dict[str, Any]) -> tuple[str, str, int | None, int | None]:
|
||||
subject = str(row.get("primary_subject") or row.get("subject") or "").strip()
|
||||
lower = subject.lower()
|
||||
if lower in ("woman", "adult woman"):
|
||||
return "woman", subject or "woman", 1, 0
|
||||
if lower in ("man", "adult man"):
|
||||
return "man", subject or "man", 0, 1
|
||||
if "two women" in lower:
|
||||
return "couple", subject or "two women", 2, 0
|
||||
if "two men" in lower:
|
||||
return "couple", subject or "two men", 0, 2
|
||||
if "woman" in lower and "man" in lower:
|
||||
return "couple", subject or "a woman and a man", 1, 1
|
||||
if "group" in lower:
|
||||
return "group", subject or "mixed adult group", 2, 2
|
||||
if "layout" in lower:
|
||||
return "layout", subject or "adult layout scene", None, None
|
||||
return "", subject, None, None
|
||||
|
||||
|
||||
_LEGACY_PROMPT_FIELD_LABELS = (
|
||||
"Ages",
|
||||
"Body types",
|
||||
"Scene",
|
||||
"Pose",
|
||||
"Facial expressions",
|
||||
"Facial expression",
|
||||
"Clothing",
|
||||
"Prop/detail",
|
||||
"Composition",
|
||||
"Use",
|
||||
"Avoid",
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
text = "" if value is None else str(value)
|
||||
text = re.sub(r"\s+", " ", text.replace("\n", " ")).strip()
|
||||
return re.sub(r"\s+([,.;:])", r"\1", text)
|
||||
|
||||
|
||||
def _legacy_prompt_field(row: dict[str, Any], label: str) -> str:
|
||||
prompt = _clean_text(row.get("prompt"))
|
||||
if not prompt:
|
||||
return ""
|
||||
labels = "|".join(re.escape(name) for name in _LEGACY_PROMPT_FIELD_LABELS)
|
||||
pattern = rf"{re.escape(label)}:\s*(.*?)(?=\. (?:{labels}):|\. Use\b|\. Avoid\b|$)"
|
||||
match = re.search(pattern, prompt)
|
||||
if not match:
|
||||
return ""
|
||||
return _clean_text(match.group(1)).rstrip(".")
|
||||
|
||||
|
||||
def _clean_legacy_pose(value: Any) -> str:
|
||||
text = _clean_text(value)
|
||||
text = text.replace(", affectionate and flirtatious but non-explicit", "")
|
||||
return text
|
||||
|
||||
|
||||
def _clean_legacy_clothing(value: Any) -> str:
|
||||
text = _clean_text(value)
|
||||
text = re.sub(r",?\s*(?:fashion editorial|resort) styling$", "", text, flags=re.IGNORECASE)
|
||||
return text.strip(" ,")
|
||||
|
||||
|
||||
def _legacy_body_phrase(row: dict[str, Any]) -> str:
|
||||
body_phrase = _clean_text(row.get("body_phrase"))
|
||||
if body_phrase:
|
||||
return body_phrase
|
||||
body = _clean_text(row.get("body_type") or row.get("body"))
|
||||
if not body:
|
||||
return ""
|
||||
figure_note = _clean_text(row.get("figure") or row.get("figure_note"))
|
||||
return _clean_text(prompt_batches.make_body_phrase(body, figure_note))
|
||||
|
||||
|
||||
def _strip_legacy_caption_lead(caption: str) -> str:
|
||||
pieces = caption.split(", ", 1)
|
||||
if len(pieces) == 2 and pieces[0].strip().lower() not in ("woman", "man"):
|
||||
return pieces[1].strip()
|
||||
return caption
|
||||
|
||||
|
||||
def _legacy_single_caption_front(row: dict[str, Any]) -> dict[str, str]:
|
||||
caption = _strip_legacy_caption_lead(_clean_text(row.get("caption")))
|
||||
if not caption:
|
||||
return {}
|
||||
|
||||
subject = _clean_text(row.get("primary_subject") or row.get("subject"))
|
||||
age = _clean_text(row.get("age_band") or row.get("age"))
|
||||
body_phrase = _legacy_body_phrase(row)
|
||||
if subject.lower() in ("woman", "man") and age and body_phrase:
|
||||
prefix = f"{subject}, {age}, {body_phrase}, "
|
||||
if caption.lower().startswith(prefix.lower()):
|
||||
try:
|
||||
skin, hair, eyes, _rest = caption[len(prefix) :].split(", ", 3)
|
||||
except ValueError:
|
||||
return {}
|
||||
return {
|
||||
"caption_subject": subject,
|
||||
"caption_age": age,
|
||||
"caption_body_phrase": body_phrase,
|
||||
"caption_skin": skin,
|
||||
"caption_hair": hair,
|
||||
"caption_eyes": eyes,
|
||||
}
|
||||
|
||||
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.lower() 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 enrich_legacy_row_metadata(row: dict[str, Any]) -> dict[str, Any]:
|
||||
if row.get("source") != "built_in_generator":
|
||||
return row
|
||||
subject_type, subject_phrase, women_count, men_count = _legacy_subject_metadata(row)
|
||||
_setdefault_nonempty(row, "subject_type", subject_type)
|
||||
_setdefault_nonempty(row, "subject_phrase", subject_phrase)
|
||||
if women_count is not None:
|
||||
_setdefault_count(row, "women_count", women_count)
|
||||
if men_count is not None:
|
||||
_setdefault_count(row, "men_count", men_count)
|
||||
if women_count is not None and men_count is not None and not str(row.get("person_count") or "").strip():
|
||||
row["person_count"] = int(women_count) + int(men_count)
|
||||
scene_slug = str(row.get("scene") or row.get("scene_slug") or "").strip()
|
||||
if scene_slug and not str(row.get("scene_slug") or "").strip():
|
||||
row["scene_slug"] = scene_slug
|
||||
if scene_slug and not str(row.get("scene_text") or "").strip():
|
||||
scene_text = row_location_policy.legacy_scene_text_for_slug(scene_slug)
|
||||
if scene_text:
|
||||
row["scene_text"] = scene_text
|
||||
row.setdefault("scene_entry", {"slug": scene_slug, "prompt": scene_text})
|
||||
if subject_type in ("woman", "man"):
|
||||
front = _legacy_single_caption_front(row)
|
||||
_setdefault_nonempty(row, "body_phrase", front.get("caption_body_phrase", ""))
|
||||
_setdefault_nonempty(row, "skin", front.get("caption_skin", ""))
|
||||
_setdefault_nonempty(row, "hair", front.get("caption_hair", ""))
|
||||
_setdefault_nonempty(row, "eyes", front.get("caption_eyes", ""))
|
||||
pose = _clean_legacy_pose(_legacy_prompt_field(row, "Pose"))
|
||||
_setdefault_nonempty(row, "pose", pose)
|
||||
expression = _legacy_prompt_field(row, "Facial expression") or _legacy_prompt_field(row, "Facial expressions")
|
||||
_setdefault_nonempty(row, "expression", expression)
|
||||
clothing = _clean_legacy_clothing(_legacy_prompt_field(row, "Clothing"))
|
||||
_setdefault_nonempty(row, "clothing", clothing)
|
||||
_setdefault_nonempty(row, "item", clothing)
|
||||
if clothing:
|
||||
_setdefault_nonempty(row, "item_label", "Clothing")
|
||||
return row
|
||||
|
||||
|
||||
def normalize_prompt_row(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
active_trigger: str,
|
||||
prepend_trigger_to_prompt: bool,
|
||||
extra_positive: str = "",
|
||||
extra_negative: str = "",
|
||||
default_negative: str = "",
|
||||
) -> dict[str, Any]:
|
||||
row = enrich_legacy_row_metadata(row)
|
||||
trigger = str(active_trigger or "").strip()
|
||||
positive = str(extra_positive or "").strip()
|
||||
prompt = str(row.get("prompt", "") or "")
|
||||
if positive:
|
||||
prompt = f"{prompt.rstrip()} {positive}".strip()
|
||||
prompt = prepend_trigger(prompt, trigger, bool(prepend_trigger_to_prompt))
|
||||
row["prompt"] = sanitize_prompt_text(prompt, triggers=_trigger_tuple(trigger))
|
||||
row["caption"] = sanitize_caption_text(row.get("caption", ""), triggers=_trigger_tuple(trigger))
|
||||
row["negative_prompt"] = sanitize_negative_text(
|
||||
combined_negative(str(row.get("negative_prompt", default_negative) or ""), extra_negative)
|
||||
)
|
||||
row["trigger"] = trigger
|
||||
return row
|
||||
|
||||
|
||||
def normalize_pair_text_outputs(
|
||||
*,
|
||||
active_trigger: str,
|
||||
prepend_trigger_to_prompt: bool,
|
||||
extra_positive: str = "",
|
||||
extra_negative: str = "",
|
||||
soft_prompt: str,
|
||||
hard_prompt: str,
|
||||
soft_negative_base: str,
|
||||
hard_negative_base: str,
|
||||
soft_caption_parts: list[Any] | tuple[Any, ...],
|
||||
hard_caption_parts: list[Any] | tuple[Any, ...],
|
||||
) -> dict[str, str]:
|
||||
trigger = str(active_trigger or "").strip()
|
||||
positive = str(extra_positive or "").strip()
|
||||
if positive:
|
||||
soft_prompt = f"{str(soft_prompt or '').rstrip()} {positive}"
|
||||
hard_prompt = f"{str(hard_prompt or '').rstrip()} {positive}"
|
||||
soft_prompt = prepend_trigger(soft_prompt, trigger, bool(prepend_trigger_to_prompt))
|
||||
hard_prompt = prepend_trigger(hard_prompt, trigger, bool(prepend_trigger_to_prompt))
|
||||
return {
|
||||
"soft_prompt": sanitize_prompt_text(soft_prompt, triggers=_trigger_tuple(trigger)),
|
||||
"hard_prompt": sanitize_prompt_text(hard_prompt, triggers=_trigger_tuple(trigger)),
|
||||
"soft_negative": sanitize_negative_text(combined_negative(soft_negative_base, extra_negative)),
|
||||
"hard_negative": sanitize_negative_text(combined_negative(hard_negative_base, extra_negative)),
|
||||
"soft_caption": caption_from_parts(soft_caption_parts, active_trigger=trigger),
|
||||
"hard_caption": caption_from_parts(hard_caption_parts, active_trigger=trigger),
|
||||
}
|
||||
|
||||
|
||||
def sanitize_metadata_row_text(row: dict[str, Any], *, active_trigger: str = "") -> dict[str, Any]:
|
||||
trigger = str(active_trigger or row.get("trigger") or "").strip()
|
||||
triggers = _trigger_tuple(trigger)
|
||||
if "prompt" in row:
|
||||
row["prompt"] = sanitize_prompt_text(row.get("prompt", ""), triggers=triggers)
|
||||
if "caption" in row:
|
||||
row["caption"] = sanitize_caption_text(row.get("caption", ""), triggers=triggers)
|
||||
if "negative_prompt" in row:
|
||||
row["negative_prompt"] = sanitize_negative_text(row.get("negative_prompt", ""))
|
||||
if trigger and not row.get("trigger"):
|
||||
row["trigger"] = trigger
|
||||
return row
|
||||
|
||||
|
||||
def _sync_pair_root_row_field(pair: dict[str, Any], row_key: str, root_key: str, row_field: str) -> None:
|
||||
row = pair.get(row_key)
|
||||
if not isinstance(row, dict):
|
||||
return
|
||||
if root_key in pair:
|
||||
row[row_field] = pair.get(root_key)
|
||||
elif row_field in row:
|
||||
pair[root_key] = row.get(row_field)
|
||||
|
||||
|
||||
def synchronize_pair_row_outputs(pair: dict[str, Any]) -> dict[str, Any]:
|
||||
mapping = (
|
||||
("softcore_row", "softcore_prompt", "softcore_caption", "softcore_negative_prompt"),
|
||||
("hardcore_row", "hardcore_prompt", "hardcore_caption", "hardcore_negative_prompt"),
|
||||
)
|
||||
for row_key, prompt_key, caption_key, negative_key in mapping:
|
||||
_sync_pair_root_row_field(pair, row_key, prompt_key, "prompt")
|
||||
_sync_pair_root_row_field(pair, row_key, caption_key, "caption")
|
||||
_sync_pair_root_row_field(pair, row_key, negative_key, "negative_prompt")
|
||||
return pair
|
||||
|
||||
|
||||
def synchronize_pair_side_metadata(pair: dict[str, Any]) -> dict[str, Any]:
|
||||
side_keys = {
|
||||
"softcore_row": (
|
||||
"softcore_partner_styling",
|
||||
),
|
||||
"hardcore_row": (
|
||||
"hardcore_clothing_state",
|
||||
"character_hardcore_clothing",
|
||||
"default_man_hardcore_clothing",
|
||||
"hardcore_detail_density",
|
||||
"hardcore_position_config",
|
||||
),
|
||||
}
|
||||
for row_key, keys in side_keys.items():
|
||||
for key in keys:
|
||||
_sync_pair_root_row_field(pair, row_key, key, key)
|
||||
return pair
|
||||
|
||||
|
||||
def synchronize_pair_camera_metadata(pair: dict[str, Any]) -> dict[str, Any]:
|
||||
mapping = {
|
||||
"softcore_row": (
|
||||
("softcore_camera_config", "camera_config"),
|
||||
("softcore_camera_directive", "camera_directive"),
|
||||
("softcore_camera_scene_directive", "camera_scene_directive"),
|
||||
),
|
||||
"hardcore_row": (
|
||||
("hardcore_camera_config", "camera_config"),
|
||||
("hardcore_camera_directive", "camera_directive"),
|
||||
("hardcore_camera_scene_directive", "camera_scene_directive"),
|
||||
),
|
||||
}
|
||||
for row_key, keys in mapping.items():
|
||||
for source_key, target_key in keys:
|
||||
_sync_pair_root_row_field(pair, row_key, source_key, target_key)
|
||||
return pair
|
||||
|
||||
|
||||
def synchronize_pair_cast_metadata(pair: dict[str, Any]) -> dict[str, Any]:
|
||||
descriptors = pair.get("shared_cast_descriptors")
|
||||
if isinstance(descriptors, list):
|
||||
descriptor_list = [str(item).strip() for item in descriptors if str(item or "").strip()]
|
||||
descriptor_text = "; ".join(descriptor_list)
|
||||
else:
|
||||
descriptor_text = str(descriptors or "").strip()
|
||||
descriptor_list = [descriptor_text] if descriptor_text else []
|
||||
if not descriptor_text:
|
||||
return pair
|
||||
|
||||
options = pair.get("options") if isinstance(pair.get("options"), dict) else {}
|
||||
row_keys = ["hardcore_row"]
|
||||
if options.get("softcore_cast") == "same_as_hardcore":
|
||||
row_keys.append("softcore_row")
|
||||
for row_key in row_keys:
|
||||
row = pair.get(row_key)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
row["cast_descriptor_text"] = descriptor_text
|
||||
row["cast_descriptors"] = list(descriptor_list)
|
||||
return pair
|
||||
|
||||
|
||||
def normalize_pair_metadata(pair: dict[str, Any], *, active_trigger: str = "") -> dict[str, Any]:
|
||||
trigger = str(active_trigger or "").strip()
|
||||
triggers = _trigger_tuple(trigger)
|
||||
synchronize_pair_row_outputs(pair)
|
||||
synchronize_pair_side_metadata(pair)
|
||||
synchronize_pair_camera_metadata(pair)
|
||||
synchronize_pair_cast_metadata(pair)
|
||||
for key in ("softcore_prompt", "hardcore_prompt"):
|
||||
if key in pair:
|
||||
pair[key] = sanitize_prompt_text(pair.get(key, ""), triggers=triggers)
|
||||
for key in ("softcore_caption", "hardcore_caption"):
|
||||
if key in pair:
|
||||
pair[key] = sanitize_caption_text(pair.get(key, ""), triggers=triggers)
|
||||
for key in ("softcore_negative_prompt", "hardcore_negative_prompt"):
|
||||
if key in pair:
|
||||
pair[key] = sanitize_negative_text(pair.get(key, ""))
|
||||
for key in ("softcore_row", "hardcore_row"):
|
||||
if isinstance(pair.get(key), dict):
|
||||
pair[key] = sanitize_metadata_row_text(pair[key], active_trigger=trigger)
|
||||
return pair
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import generate_prompt_batches as g
|
||||
from . import location_config as location_policy
|
||||
except ImportError: # Allows local smoke tests with top-level imports.
|
||||
import category_library as category_policy
|
||||
import generate_prompt_batches as g
|
||||
import location_config as location_policy
|
||||
|
||||
|
||||
def _list_from(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def _is_false(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("false", "0", "no", "off")
|
||||
return False
|
||||
|
||||
|
||||
def _unique_extend(target: list[Any], additions: list[Any]) -> None:
|
||||
seen = set()
|
||||
for item in target:
|
||||
try:
|
||||
seen.add(json.dumps(item, sort_keys=True))
|
||||
except TypeError:
|
||||
seen.add(repr(item))
|
||||
for item in additions:
|
||||
try:
|
||||
marker = json.dumps(item, sort_keys=True)
|
||||
except TypeError:
|
||||
marker = repr(item)
|
||||
if marker not in seen:
|
||||
target.append(item)
|
||||
seen.add(marker)
|
||||
|
||||
|
||||
def scene_pool(
|
||||
category: dict[str, Any],
|
||||
subcategory: dict[str, Any],
|
||||
item: Any,
|
||||
subject_type: str,
|
||||
location_config: dict[str, Any] | None = None,
|
||||
) -> list[Any]:
|
||||
location_config = location_config or {}
|
||||
location_entries = _list_from(location_config.get("scene_entries"))
|
||||
if location_policy.location_config_active(location_config) and location_config.get("apply_mode") == "replace":
|
||||
return location_entries
|
||||
fallback = g.GROUP_SCENES if subject_type in ("group", "configured_cast") else g.SCENES
|
||||
scene_entries: list[Any] = []
|
||||
scene_pools = category_policy.load_scene_pool_library()
|
||||
item_source = item if isinstance(item, dict) else None
|
||||
if item_source is not None and _is_false(item_source.get("inherit_scenes")):
|
||||
sources = (item_source,)
|
||||
elif _is_false(subcategory.get("inherit_scenes")):
|
||||
sources = (subcategory, item_source)
|
||||
else:
|
||||
sources = (category, subcategory, item_source)
|
||||
for source in sources:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
if "scenes" in source:
|
||||
_unique_extend(scene_entries, _list_from(source["scenes"]))
|
||||
refs = _list_from(source.get("scene_pool")) + _list_from(source.get("scene_pools"))
|
||||
for ref in refs:
|
||||
ref_name = str(ref).strip()
|
||||
if ref_name not in scene_pools:
|
||||
raise ValueError(f"Unknown scene pool '{ref_name}'")
|
||||
_unique_extend(scene_entries, scene_pools[ref_name])
|
||||
if location_policy.location_config_active(location_config) and location_config.get("apply_mode") == "add":
|
||||
_unique_extend(scene_entries, location_entries)
|
||||
return scene_entries or fallback
|
||||
|
||||
|
||||
def expression_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any) -> list[Any]:
|
||||
return category_policy.configured_pool(
|
||||
category,
|
||||
subcategory,
|
||||
item,
|
||||
"expressions",
|
||||
"expression_pools",
|
||||
category_policy.load_expression_pool_library(),
|
||||
"inherit_expressions",
|
||||
) or g.EXPRESSIONS
|
||||
|
||||
|
||||
def pose_pool(category: dict[str, Any], subcategory: dict[str, Any], item: Any, subject_type: str, poses: str) -> list[Any]:
|
||||
configured = category_policy.merged_field(category, subcategory, item, "poses")
|
||||
if configured:
|
||||
return _list_from(configured)
|
||||
if subject_type == "couple":
|
||||
return [entry[2] for entry in g.COUPLE_TYPES]
|
||||
if subject_type in ("layout", "scene"):
|
||||
return ["clean designed layout"]
|
||||
return g.EVOCATIVE_ALL if poses == "evocative" else g.POSES
|
||||
|
||||
|
||||
def composition_pool(
|
||||
category: dict[str, Any],
|
||||
subcategory: dict[str, Any],
|
||||
item: Any,
|
||||
subject_type: str,
|
||||
composition_config: dict[str, Any] | None = None,
|
||||
) -> list[Any]:
|
||||
composition_config = composition_config or {}
|
||||
composition_entries = _list_from(composition_config.get("composition_entries"))
|
||||
if location_policy.composition_config_active(composition_config) and composition_config.get("apply_mode") == "replace":
|
||||
return composition_entries
|
||||
configured = category_policy.configured_pool(
|
||||
category,
|
||||
subcategory,
|
||||
item,
|
||||
"compositions",
|
||||
"composition_pools",
|
||||
category_policy.load_composition_pool_library(),
|
||||
"inherit_compositions",
|
||||
)
|
||||
if location_policy.composition_config_active(composition_config) and composition_config.get("apply_mode") == "add":
|
||||
configured = list(configured or [])
|
||||
_unique_extend(configured, composition_entries)
|
||||
if configured:
|
||||
return configured
|
||||
if subject_type in ("group", "configured_cast"):
|
||||
return g.GROUP_COMPOSITIONS
|
||||
if subject_type in ("layout", "scene"):
|
||||
return ["designed illustration layout"]
|
||||
return g.COMPOSITIONS
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import row_expression as row_expression_policy
|
||||
from . import row_item as row_item_policy
|
||||
from . import row_pools as row_pool_policy
|
||||
from . import pov_policy
|
||||
from .hardcore_text_cleanup import sanitize_hardcore_environment_anchors
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import category_library as category_policy
|
||||
import row_expression as row_expression_policy
|
||||
import row_item as row_item_policy
|
||||
import row_pools as row_pool_policy
|
||||
import pov_policy
|
||||
from hardcore_text_cleanup import sanitize_hardcore_environment_anchors
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptAxesRoute:
|
||||
scene_slug: str
|
||||
scene: str
|
||||
scene_entry: dict[str, Any]
|
||||
pose: str
|
||||
expression: str
|
||||
shared_expression: str
|
||||
character_expressions: list[str]
|
||||
character_expression_text: str
|
||||
source_composition: str
|
||||
composition: str
|
||||
composition_entry: dict[str, Any]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"scene_slug": self.scene_slug,
|
||||
"scene": self.scene,
|
||||
"scene_entry": dict(self.scene_entry),
|
||||
"pose": self.pose,
|
||||
"expression": self.expression,
|
||||
"shared_expression": self.shared_expression,
|
||||
"character_expressions": list(self.character_expressions),
|
||||
"character_expression_text": self.character_expression_text,
|
||||
"source_composition": self.source_composition,
|
||||
"composition": self.composition,
|
||||
"composition_entry": dict(self.composition_entry),
|
||||
}
|
||||
|
||||
|
||||
def _metadata_entry(value: Any, *, slug: str = "", text: str = "") -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
entry = dict(value)
|
||||
elif isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
entry = {"slug": str(value[0]), "prompt": str(value[1])}
|
||||
else:
|
||||
entry = {"prompt": str(value or "")}
|
||||
if slug:
|
||||
entry["slug"] = slug
|
||||
if text:
|
||||
if "prompt" in entry:
|
||||
entry["prompt"] = text
|
||||
elif "text" in entry:
|
||||
entry["text"] = text
|
||||
else:
|
||||
entry["prompt"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def resolve_prompt_axes_result(
|
||||
*,
|
||||
category: dict[str, Any],
|
||||
subcategory: dict[str, Any],
|
||||
item: Any,
|
||||
subject_type: str,
|
||||
context: dict[str, Any],
|
||||
poses: str,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
scene_rng: Any,
|
||||
pose_rng: Any,
|
||||
expression_rng: Any,
|
||||
composition_rng: Any,
|
||||
expression_disabled: bool,
|
||||
expression_intensity: float,
|
||||
character_slots: list[dict[str, Any]] | None = None,
|
||||
character_slot_map: dict[str, dict[str, Any]] | None = None,
|
||||
expression_phase: str = "",
|
||||
source_role_graph: Any = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
is_pose_category: bool = False,
|
||||
pov_character_labels: list[str] | None = None,
|
||||
location_config: dict[str, Any] | None = None,
|
||||
composition_config: dict[str, Any] | None = None,
|
||||
) -> PromptAxesRoute:
|
||||
character_slots = character_slots or []
|
||||
character_slot_map = character_slot_map or {}
|
||||
pov_character_labels = pov_character_labels or []
|
||||
|
||||
scene_entries = category_policy.compatible_entries(
|
||||
row_pool_policy.scene_pool(category, subcategory, item, subject_type, location_config),
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
scene_choice = row_item_policy.weighted_choice(scene_rng, scene_entries)
|
||||
scene_slug, scene = row_item_policy.pair_from(scene_choice)
|
||||
scene_entry = _metadata_entry(scene_choice, slug=scene_slug, text=scene)
|
||||
pose = str(
|
||||
category_policy.merged_field(category, subcategory, item, "pose", "")
|
||||
or context.get("fallback_pose")
|
||||
or row_item_policy.choose_text(
|
||||
pose_rng,
|
||||
category_policy.compatible_entries(
|
||||
row_pool_policy.pose_pool(category, subcategory, item, subject_type, poses),
|
||||
women_count,
|
||||
men_count,
|
||||
),
|
||||
)
|
||||
)
|
||||
if is_pose_category:
|
||||
pose = sanitize_hardcore_environment_anchors(pose)
|
||||
|
||||
expression_pool = row_pool_policy.expression_pool(category, subcategory, item)
|
||||
if expression_disabled:
|
||||
expression = ""
|
||||
else:
|
||||
expression_entries = category_policy.compatible_entries(
|
||||
row_expression_policy.expression_entries_for_intensity(expression_pool, expression_intensity),
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
expression = row_item_policy.choose_text(expression_rng, expression_entries)
|
||||
if subject_type in ("couple", "group") and ";" not in expression:
|
||||
secondary_expression = row_item_policy.choose_distinct_text(expression_rng, expression_entries, expression)
|
||||
if secondary_expression:
|
||||
expression = f"{expression}; {secondary_expression}"
|
||||
|
||||
shared_expression = expression
|
||||
character_expressions: list[str] = []
|
||||
character_expression_text = ""
|
||||
if not expression_disabled and subject_type == "configured_cast" and character_slots:
|
||||
character_expressions = row_expression_policy.character_expression_entries(
|
||||
expression_rng,
|
||||
expression_pool,
|
||||
expression_intensity,
|
||||
character_slot_map,
|
||||
women_count,
|
||||
men_count,
|
||||
expression_phase,
|
||||
)
|
||||
character_expression_text = "; ".join(character_expressions)
|
||||
character_expression_text = row_expression_policy.sanitize_character_expression_text_for_action(
|
||||
character_expression_text,
|
||||
source_role_graph,
|
||||
item,
|
||||
item_axis_values or {},
|
||||
)
|
||||
character_expressions = [part.strip() for part in character_expression_text.split(";") if part.strip()]
|
||||
if character_expression_text:
|
||||
expression = character_expression_text
|
||||
|
||||
composition_entries = category_policy.compatible_entries(
|
||||
row_pool_policy.composition_pool(category, subcategory, item, subject_type, composition_config),
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
composition_choice = row_item_policy.weighted_choice(composition_rng, composition_entries)
|
||||
source_composition = row_item_policy.item_text(composition_choice)
|
||||
composition_entry = _metadata_entry(composition_choice, text=source_composition)
|
||||
if is_pose_category:
|
||||
source_composition = sanitize_hardcore_environment_anchors(source_composition)
|
||||
composition_entry["prompt"] = source_composition
|
||||
composition = pov_policy.pov_composition_prompt(source_composition, pov_character_labels)
|
||||
|
||||
return PromptAxesRoute(
|
||||
scene_slug=scene_slug,
|
||||
scene=scene,
|
||||
scene_entry=scene_entry,
|
||||
pose=pose,
|
||||
expression=expression,
|
||||
shared_expression=shared_expression,
|
||||
character_expressions=character_expressions,
|
||||
character_expression_text=character_expression_text,
|
||||
source_composition=source_composition,
|
||||
composition=composition,
|
||||
composition_entry=composition_entry,
|
||||
)
|
||||
|
||||
|
||||
def resolve_prompt_axes(
|
||||
*,
|
||||
category: dict[str, Any],
|
||||
subcategory: dict[str, Any],
|
||||
item: Any,
|
||||
subject_type: str,
|
||||
context: dict[str, Any],
|
||||
poses: str,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
scene_rng: Any,
|
||||
pose_rng: Any,
|
||||
expression_rng: Any,
|
||||
composition_rng: Any,
|
||||
expression_disabled: bool,
|
||||
expression_intensity: float,
|
||||
character_slots: list[dict[str, Any]] | None = None,
|
||||
character_slot_map: dict[str, dict[str, Any]] | None = None,
|
||||
expression_phase: str = "",
|
||||
source_role_graph: Any = "",
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
is_pose_category: bool = False,
|
||||
pov_character_labels: list[str] | None = None,
|
||||
location_config: dict[str, Any] | None = None,
|
||||
composition_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return resolve_prompt_axes_result(
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
item=item,
|
||||
subject_type=subject_type,
|
||||
context=context,
|
||||
poses=poses,
|
||||
women_count=women_count,
|
||||
men_count=men_count,
|
||||
scene_rng=scene_rng,
|
||||
pose_rng=pose_rng,
|
||||
expression_rng=expression_rng,
|
||||
composition_rng=composition_rng,
|
||||
expression_disabled=expression_disabled,
|
||||
expression_intensity=expression_intensity,
|
||||
character_slots=character_slots,
|
||||
character_slot_map=character_slot_map,
|
||||
expression_phase=expression_phase,
|
||||
source_role_graph=source_role_graph,
|
||||
item_axis_values=item_axis_values,
|
||||
is_pose_category=is_pose_category,
|
||||
pov_character_labels=pov_character_labels,
|
||||
location_config=location_config,
|
||||
composition_config=composition_config,
|
||||
).as_dict()
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from string import Formatter
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_library as category_policy
|
||||
from . import generate_prompt_batches as g
|
||||
from . import row_camera as row_camera_policy
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import category_library as category_policy
|
||||
import generate_prompt_batches as g
|
||||
import row_camera as row_camera_policy
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
DEFAULT_STYLE = "sexy but tasteful adult pin-up coloured-pencil comic illustration"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RowTextFields:
|
||||
negative_prompt: str
|
||||
positive_suffix: str
|
||||
style: str
|
||||
item_label: str
|
||||
|
||||
|
||||
SINGLE_TEMPLATE = (
|
||||
"A {subject}: {style}, {age}, {body_phrase}, {skin}, {hair}, {eyes}. "
|
||||
"{item_label}: {item}. Scene: {scene}. Pose: {pose}. Facial expression: {expression}. "
|
||||
"Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}."
|
||||
)
|
||||
|
||||
COUPLE_TEMPLATE = (
|
||||
"{subject_phrase}: {style}. Ages: {age}. Body types: {body}. {item_label}: {item}. "
|
||||
"Scene: {scene}. Pose: {pose}. Facial expressions: {expression}. "
|
||||
"Composition: {composition_prompt}. {positive_suffix} Avoid: {negative_prompt}."
|
||||
)
|
||||
|
||||
GROUP_TEMPLATE = (
|
||||
"{subject_phrase}: {style}, ages {age}, diverse adult body types. {item_label}: {item}. "
|
||||
"Scene: {scene}. Facial expressions: {expression}. Composition: {composition_prompt}. "
|
||||
"{positive_suffix} Avoid: {negative_prompt}."
|
||||
)
|
||||
|
||||
LAYOUT_TEMPLATE = (
|
||||
"{item}: {style}, adults only, clean designed composition. Scene: {scene}. "
|
||||
"Facial expression: {expression}. Composition: {composition}. {positive_suffix} "
|
||||
"Avoid: {negative_prompt}. Use no readable text unless the layout naturally needs small decorative placeholder marks."
|
||||
)
|
||||
|
||||
DEFAULT_CAPTION_TEMPLATE = (
|
||||
"{trigger}, {subject_phrase}, {age}, {item}, {scene}, {composition}, coloured pencil comic illustration"
|
||||
)
|
||||
|
||||
|
||||
class SafeFormatDict(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def format_template(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 resolve_row_text_fields(category: dict[str, Any], subcategory: dict[str, Any], item: Any) -> RowTextFields:
|
||||
return RowTextFields(
|
||||
negative_prompt=str(
|
||||
category_policy.merged_field(category, subcategory, item, "negative_prompt", g.NEGATIVE_PROMPT)
|
||||
),
|
||||
positive_suffix=str(
|
||||
category_policy.merged_field(category, subcategory, item, "positive_suffix", GENERIC_POSITIVE_SUFFIX)
|
||||
),
|
||||
style=str(category_policy.merged_field(category, subcategory, item, "style", DEFAULT_STYLE)),
|
||||
item_label=str(category_policy.merged_field(category, subcategory, item, "item_label", category["name"])),
|
||||
)
|
||||
|
||||
|
||||
def default_prompt_template(subject_type: str) -> str:
|
||||
if subject_type in ("woman", "man"):
|
||||
return SINGLE_TEMPLATE
|
||||
if subject_type == "couple":
|
||||
return COUPLE_TEMPLATE
|
||||
if subject_type == "group":
|
||||
return GROUP_TEMPLATE
|
||||
return LAYOUT_TEMPLATE
|
||||
|
||||
|
||||
def prompt_template_for(item: Any, subcategory: dict[str, Any], category: dict[str, Any], subject_type: str) -> str:
|
||||
if isinstance(item, dict) and "prompt_template" in item:
|
||||
return str(item["prompt_template"])
|
||||
template = str(subcategory.get("prompt_template") or category.get("prompt_template") or "")
|
||||
return template or default_prompt_template(subject_type)
|
||||
|
||||
|
||||
def caption_template_for(item: Any, subcategory: dict[str, Any], category: dict[str, Any]) -> str:
|
||||
return str(
|
||||
(item.get("caption_template") if isinstance(item, dict) else None)
|
||||
or subcategory.get("caption_template")
|
||||
or category.get("caption_template")
|
||||
or DEFAULT_CAPTION_TEMPLATE
|
||||
)
|
||||
|
||||
|
||||
def render_prompt_caption(
|
||||
*,
|
||||
item: Any,
|
||||
subcategory: dict[str, Any],
|
||||
category: dict[str, Any],
|
||||
subject_type: str,
|
||||
context: dict[str, Any],
|
||||
cast_descriptor_text: str = "",
|
||||
pov_prompt_directive: str = "",
|
||||
) -> dict[str, str]:
|
||||
prompt_template = prompt_template_for(item, subcategory, category, subject_type)
|
||||
caption_template = caption_template_for(item, subcategory, category)
|
||||
|
||||
prompt = format_template(prompt_template, context)
|
||||
if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in prompt_template:
|
||||
prompt = row_camera_policy.insert_positive_directive(prompt, f"Characters: {cast_descriptor_text}.")
|
||||
if subject_type == "configured_cast" and pov_prompt_directive:
|
||||
prompt = row_camera_policy.insert_positive_directive(prompt, pov_prompt_directive)
|
||||
|
||||
caption = format_template(caption_template, context)
|
||||
if subject_type == "configured_cast" and cast_descriptor_text and "{cast_descriptors}" not in caption_template:
|
||||
caption = f"{caption.rstrip()}, {cast_descriptor_text}"
|
||||
|
||||
return {
|
||||
"prompt": prompt,
|
||||
"caption": caption,
|
||||
"prompt_template": prompt_template,
|
||||
"caption_template": caption_template,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import hardcore_role_graphs
|
||||
from . import hardcore_text_cleanup
|
||||
from . import pov_policy
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import hardcore_role_graphs
|
||||
import hardcore_text_cleanup
|
||||
import pov_policy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleGraphRoute:
|
||||
source_role_graph: str
|
||||
role_graph: str
|
||||
|
||||
|
||||
def resolve_role_graph_route(
|
||||
*,
|
||||
rng: random.Random,
|
||||
subcategory: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
item_axis_values: dict[str, Any],
|
||||
pov_character_labels: list[str],
|
||||
is_pose_category: bool,
|
||||
) -> RoleGraphRoute:
|
||||
source_role_graph = hardcore_role_graphs.build_hardcore_role_graph(
|
||||
rng,
|
||||
subcategory,
|
||||
context,
|
||||
item_axis_values,
|
||||
pov_character_labels,
|
||||
)
|
||||
if is_pose_category:
|
||||
source_role_graph = hardcore_text_cleanup.sanitize_hardcore_environment_anchors(source_role_graph)
|
||||
role_graph = pov_policy.pov_role_graph_prompt(source_role_graph, pov_character_labels)
|
||||
return RoleGraphRoute(source_role_graph=source_role_graph, role_graph=role_graph)
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import category_template_metadata as template_policy
|
||||
from . import hardcore_position_config as hardcore_position_policy
|
||||
from .hardcore_action_metadata import source_hardcore_action_family
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import category_template_metadata as template_policy
|
||||
import hardcore_position_config as hardcore_position_policy
|
||||
from hardcore_action_metadata import source_hardcore_action_family
|
||||
|
||||
|
||||
EMPTY_ACTION_POSITION_ROUTE = {
|
||||
"position_family": "",
|
||||
"position_keys": [],
|
||||
"position_key": "",
|
||||
"action_family": "",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionPositionRoute:
|
||||
position_family: str
|
||||
position_keys: list[str]
|
||||
position_key: str
|
||||
action_family: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"position_family": self.position_family,
|
||||
"position_keys": list(self.position_keys),
|
||||
"position_key": self.position_key,
|
||||
"action_family": self.action_family,
|
||||
}
|
||||
|
||||
|
||||
def empty_action_position_route_result() -> ActionPositionRoute:
|
||||
return ActionPositionRoute(
|
||||
position_family="",
|
||||
position_keys=[],
|
||||
position_key="",
|
||||
action_family="",
|
||||
)
|
||||
|
||||
|
||||
def empty_action_position_route() -> dict[str, Any]:
|
||||
return empty_action_position_route_result().as_dict()
|
||||
|
||||
|
||||
def _primary_position_key(
|
||||
position_keys: list[str],
|
||||
metadata: dict[str, Any],
|
||||
hardcore_position_config: dict[str, Any] | None,
|
||||
) -> str:
|
||||
if not position_keys:
|
||||
return ""
|
||||
configured = []
|
||||
if isinstance(hardcore_position_config, dict):
|
||||
configured = hardcore_position_policy.normalize_hardcore_position_values(
|
||||
hardcore_position_config.get("positions")
|
||||
)
|
||||
for key in configured:
|
||||
if key in position_keys:
|
||||
return key
|
||||
for key in template_policy.template_position_keys(metadata):
|
||||
if key in position_keys:
|
||||
return key
|
||||
return position_keys[0]
|
||||
|
||||
|
||||
def resolve_action_position_route_result(
|
||||
*,
|
||||
is_pose_category: bool,
|
||||
subcategory: dict[str, Any],
|
||||
hardcore_position_config: dict[str, Any] | None,
|
||||
item_template_metadata: dict[str, Any] | None,
|
||||
item_text: Any,
|
||||
source_role_graph: Any,
|
||||
source_composition: Any,
|
||||
pose: Any,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> ActionPositionRoute:
|
||||
if not is_pose_category:
|
||||
return empty_action_position_route_result()
|
||||
|
||||
metadata = item_template_metadata or {}
|
||||
position_family = template_policy.template_position_family(
|
||||
metadata
|
||||
) or hardcore_position_policy.hardcore_source_position_family(
|
||||
subcategory,
|
||||
hardcore_position_config,
|
||||
)
|
||||
inferred_position_keys = hardcore_position_policy.hardcore_position_keys(
|
||||
item_text,
|
||||
source_role_graph,
|
||||
source_composition,
|
||||
pose,
|
||||
axis_values=item_axis_values,
|
||||
)
|
||||
position_keys = template_policy.merge_position_keys(
|
||||
template_policy.template_position_keys(metadata),
|
||||
inferred_position_keys,
|
||||
)
|
||||
explicit_action_family = template_policy.template_action_family(metadata)
|
||||
action_family = "" if explicit_action_family == "default" else explicit_action_family
|
||||
if not action_family:
|
||||
action_family = source_hardcore_action_family(
|
||||
position_family,
|
||||
source_role_graph,
|
||||
item_text,
|
||||
source_composition,
|
||||
item_axis_values,
|
||||
)
|
||||
|
||||
return ActionPositionRoute(
|
||||
position_family=position_family,
|
||||
position_keys=position_keys,
|
||||
position_key=_primary_position_key(position_keys, metadata, hardcore_position_config),
|
||||
action_family=action_family,
|
||||
)
|
||||
|
||||
|
||||
def resolve_action_position_route(
|
||||
*,
|
||||
is_pose_category: bool,
|
||||
subcategory: dict[str, Any],
|
||||
hardcore_position_config: dict[str, Any] | None,
|
||||
item_template_metadata: dict[str, Any] | None,
|
||||
item_text: Any,
|
||||
source_role_graph: Any,
|
||||
source_composition: Any,
|
||||
pose: Any,
|
||||
item_axis_values: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return resolve_action_position_route_result(
|
||||
is_pose_category=is_pose_category,
|
||||
subcategory=subcategory,
|
||||
hardcore_position_config=hardcore_position_config,
|
||||
item_template_metadata=item_template_metadata,
|
||||
item_text=item_text,
|
||||
source_role_graph=source_role_graph,
|
||||
source_composition=source_composition,
|
||||
pose=pose,
|
||||
item_axis_values=item_axis_values,
|
||||
).as_dict()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from . import cast_context as cast_context_policy
|
||||
from . import character_appearance as character_appearance_policy
|
||||
from . import character_profile as character_profile_policy
|
||||
from . import character_slot as character_slot_policy
|
||||
from . import pair_cast
|
||||
from . import pov_policy
|
||||
from . import seed_config as seed_policy
|
||||
from . import subject_context as subject_context_policy
|
||||
except ImportError: # Allows local smoke tests from the repository root.
|
||||
import cast_context as cast_context_policy
|
||||
import character_appearance as character_appearance_policy
|
||||
import character_profile as character_profile_policy
|
||||
import character_slot as character_slot_policy
|
||||
import pair_cast
|
||||
import pov_policy
|
||||
import seed_config as seed_policy
|
||||
import subject_context as subject_context_policy
|
||||
|
||||
|
||||
def resolve_subject_route(
|
||||
*,
|
||||
subject_type: str,
|
||||
seed_config: dict[str, int],
|
||||
seed: int,
|
||||
row_number: int,
|
||||
ethnicity: str,
|
||||
figure: str,
|
||||
no_plus_women: bool,
|
||||
no_black: bool,
|
||||
women_count: int,
|
||||
men_count: int,
|
||||
character_profile: str | dict[str, Any] | None = None,
|
||||
character_cast: str | dict[str, Any] | list[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
person_rng = seed_policy.axis_rng(seed_config, "person", seed, row_number)
|
||||
context = subject_context_policy.subject_context(
|
||||
person_rng,
|
||||
subject_type,
|
||||
ethnicity,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
women_count,
|
||||
men_count,
|
||||
)
|
||||
character_slots = character_slot_policy.parse_character_cast(character_cast)
|
||||
character_slot_map = cast_context_policy.character_slot_label_map(character_slots)
|
||||
applied_slot: dict[str, Any] = {}
|
||||
slot_status = "none"
|
||||
if context.get("subject_type") in ("woman", "man"):
|
||||
slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A"
|
||||
if slot_label in character_slot_map:
|
||||
context, applied_slot = character_appearance_policy.character_context_for_label(
|
||||
slot_label,
|
||||
character_slot_map,
|
||||
person_rng,
|
||||
ethnicity,
|
||||
figure,
|
||||
no_plus_women,
|
||||
no_black,
|
||||
)
|
||||
slot_status = f"applied:{slot_label}"
|
||||
applied_profile, profile_status = {}, "skipped_character_slot"
|
||||
else:
|
||||
context, applied_profile, profile_status = character_profile_policy.apply_character_profile_to_context(
|
||||
context,
|
||||
character_profile,
|
||||
)
|
||||
else:
|
||||
context, applied_profile, profile_status = character_profile_policy.apply_character_profile_to_context(
|
||||
context,
|
||||
character_profile,
|
||||
)
|
||||
|
||||
resolved_subject_type = str(context.get("subject_type") or subject_type)
|
||||
pov_character_labels = (
|
||||
pov_policy.pov_character_labels(character_slot_map, men_count)
|
||||
if resolved_subject_type == "configured_cast"
|
||||
else []
|
||||
)
|
||||
cast_descriptors: list[str] = []
|
||||
cast_descriptor_text = ""
|
||||
if resolved_subject_type == "configured_cast" and character_slots:
|
||||
cast_descriptors, _descriptor_slots = pair_cast.cast_descriptor_entries_from_slots(
|
||||
seed_config=seed_config,
|
||||
seed=seed,
|
||||
row_number=row_number,
|
||||
ethnicity=ethnicity,
|
||||
figure=figure,
|
||||
no_plus_women=no_plus_women,
|
||||
no_black=no_black,
|
||||
women_count=women_count,
|
||||
men_count=men_count,
|
||||
character_slots=character_slots,
|
||||
character_slot_map=character_slot_map,
|
||||
primary_descriptor="",
|
||||
axis_rng=seed_policy.axis_rng,
|
||||
character_context_for_label=character_appearance_policy.character_context_for_label,
|
||||
slot_is_pov=pov_policy.slot_is_pov,
|
||||
)
|
||||
cast_descriptor_text = pair_cast.prompt_cast_descriptors("; ".join(cast_descriptors))
|
||||
|
||||
return {
|
||||
"context": context,
|
||||
"subject_type": resolved_subject_type,
|
||||
"character_slots": character_slots,
|
||||
"character_slot_map": character_slot_map,
|
||||
"applied_slot": applied_slot or {},
|
||||
"character_slot_status": slot_status,
|
||||
"applied_profile": applied_profile or {},
|
||||
"character_profile_status": profile_status,
|
||||
"pov_character_labels": pov_character_labels,
|
||||
"cast_descriptors": cast_descriptors,
|
||||
"cast_descriptor_text": cast_descriptor_text,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
from . import formatter_input as input_policy
|
||||
from . import formatter_route_trace as trace_policy
|
||||
from . import formatter_target as target_policy
|
||||
except ImportError: # pragma: no cover - plain-script smoke tests
|
||||
import formatter_input as input_policy
|
||||
import formatter_route_trace as trace_policy
|
||||
import formatter_target as target_policy
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SDXLFormatRequest:
|
||||
source_text: str
|
||||
metadata_json: str = ""
|
||||
negative_prompt: str = ""
|
||||
input_hint: str = "auto"
|
||||
target: str = "auto"
|
||||
style_preset: str = "flat_vector_pony"
|
||||
quality_preset: str = "pony_high"
|
||||
trigger: str = "mythp0rt"
|
||||
prepend_trigger: bool = True
|
||||
preserve_trigger: bool = False
|
||||
nude_weight: float = 1.29
|
||||
custom_style: str = ""
|
||||
custom_quality: str = ""
|
||||
extra_positive: str = ""
|
||||
extra_negative: str = ""
|
||||
formatter_profile: str = "manual_controls"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SDXLFormatRoute:
|
||||
output: dict[str, str]
|
||||
branch: str
|
||||
method: str
|
||||
target: str
|
||||
style_preset: str
|
||||
quality_preset: str
|
||||
nude_weight: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SDXLFormatDependencies:
|
||||
default_negative: str
|
||||
apply_formatter_profile: Callable[[str, str, str], tuple[str, str]]
|
||||
clean: Callable[[Any], str]
|
||||
row_from_inputs: Callable[[str, str, str], tuple[dict[str, Any] | None, str]]
|
||||
row_core_tags: Callable[[dict[str, Any], float], list[str]]
|
||||
soft_tags: Callable[[dict[str, Any], dict[str, Any], float], str]
|
||||
hard_tags: Callable[[dict[str, Any], dict[str, Any], float], str]
|
||||
fallback_text_to_sdxl: Callable[[str, bool, float], tuple[str, str, str]]
|
||||
assemble_prompt: Callable[[str, str, str, str, bool, str, str, str], str]
|
||||
combine_negative: Callable[..., str]
|
||||
sanitize_negative_text: Callable[[str], str]
|
||||
|
||||
|
||||
def format_sdxl_prompt_result(request: SDXLFormatRequest, deps: SDXLFormatDependencies) -> SDXLFormatRoute:
|
||||
style_preset, quality_preset = deps.apply_formatter_profile(
|
||||
request.formatter_profile,
|
||||
request.style_preset,
|
||||
request.quality_preset,
|
||||
)
|
||||
target = target_policy.normalize_target(request.target)
|
||||
input_hint = input_policy.normalize_input_hint(request.input_hint, text_hint=input_policy.INPUT_HINT_PROMPT)
|
||||
nude_weight = max(0.1, min(3.0, float(request.nude_weight)))
|
||||
row, method = deps.row_from_inputs(request.source_text, request.metadata_json, request.input_hint)
|
||||
|
||||
if row and input_policy.is_pair_metadata(row):
|
||||
pair_target = target_policy.pair_policy(target)
|
||||
soft_row = row.get("softcore_row") if isinstance(row.get("softcore_row"), dict) else {}
|
||||
hard_row = row.get("hardcore_row") if isinstance(row.get("hardcore_row"), dict) else {}
|
||||
soft_body = deps.soft_tags(soft_row, row, nude_weight)
|
||||
hard_body = deps.hard_tags(hard_row, row, nude_weight)
|
||||
soft_prompt = deps.assemble_prompt(
|
||||
soft_body,
|
||||
style_preset,
|
||||
quality_preset,
|
||||
request.trigger,
|
||||
request.prepend_trigger,
|
||||
request.custom_style,
|
||||
request.custom_quality,
|
||||
request.extra_positive,
|
||||
)
|
||||
hard_prompt = deps.assemble_prompt(
|
||||
hard_body,
|
||||
style_preset,
|
||||
quality_preset,
|
||||
request.trigger,
|
||||
request.prepend_trigger,
|
||||
request.custom_style,
|
||||
request.custom_quality,
|
||||
request.extra_positive,
|
||||
)
|
||||
selected = hard_prompt if pair_target.selected_side == "hardcore" else soft_prompt
|
||||
selected_negative = (
|
||||
row.get("hardcore_negative_prompt")
|
||||
if pair_target.selected_side == "hardcore"
|
||||
else row.get("softcore_negative_prompt")
|
||||
)
|
||||
output = {
|
||||
"sdxl_prompt": selected,
|
||||
"negative_prompt": deps.sanitize_negative_text(
|
||||
deps.combine_negative(
|
||||
deps.default_negative,
|
||||
selected_negative,
|
||||
request.negative_prompt,
|
||||
request.extra_negative,
|
||||
)
|
||||
),
|
||||
"sdxl_softcore_prompt": soft_prompt,
|
||||
"sdxl_hardcore_prompt": hard_prompt,
|
||||
"softcore_negative_prompt": deps.sanitize_negative_text(
|
||||
deps.combine_negative(deps.default_negative, row.get("softcore_negative_prompt"), request.extra_negative)
|
||||
),
|
||||
"hardcore_negative_prompt": deps.sanitize_negative_text(
|
||||
deps.combine_negative(deps.default_negative, row.get("hardcore_negative_prompt"), request.extra_negative)
|
||||
),
|
||||
"method": f"{method}:sdxl(insta_of_pair)",
|
||||
}
|
||||
output["route_trace_json"] = trace_policy.route_trace_json(
|
||||
formatter="sdxl",
|
||||
branch="insta_of_pair",
|
||||
method=output["method"],
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
style_preset=style_preset,
|
||||
quality_preset=quality_preset,
|
||||
nude_weight=nude_weight,
|
||||
**trace_policy.metadata_trace_fields(row, target=target, selected_side=pair_target.selected_side),
|
||||
)
|
||||
return SDXLFormatRoute(
|
||||
output=output,
|
||||
branch="insta_of_pair",
|
||||
method=output["method"],
|
||||
target=target,
|
||||
style_preset=style_preset,
|
||||
quality_preset=quality_preset,
|
||||
nude_weight=nude_weight,
|
||||
)
|
||||
|
||||
if row:
|
||||
body = ", ".join(deps.row_core_tags(row, nude_weight))
|
||||
extracted_negative = deps.clean(row.get("negative_prompt"))
|
||||
method = f"{method}:sdxl(metadata)"
|
||||
branch = "metadata"
|
||||
else:
|
||||
body, extracted_negative, method = deps.fallback_text_to_sdxl(
|
||||
request.source_text,
|
||||
request.preserve_trigger,
|
||||
nude_weight,
|
||||
)
|
||||
branch = "fallback"
|
||||
|
||||
prompt = deps.assemble_prompt(
|
||||
body,
|
||||
style_preset,
|
||||
quality_preset,
|
||||
request.trigger,
|
||||
request.prepend_trigger,
|
||||
request.custom_style,
|
||||
request.custom_quality,
|
||||
request.extra_positive,
|
||||
)
|
||||
output = {
|
||||
"sdxl_prompt": prompt,
|
||||
"negative_prompt": deps.sanitize_negative_text(
|
||||
deps.combine_negative(deps.default_negative, extracted_negative, request.negative_prompt, request.extra_negative)
|
||||
),
|
||||
"sdxl_softcore_prompt": "",
|
||||
"sdxl_hardcore_prompt": "",
|
||||
"softcore_negative_prompt": "",
|
||||
"hardcore_negative_prompt": "",
|
||||
"method": method,
|
||||
}
|
||||
output["route_trace_json"] = trace_policy.route_trace_json(
|
||||
formatter="sdxl",
|
||||
branch=branch,
|
||||
method=method,
|
||||
input_hint=input_hint,
|
||||
target=target,
|
||||
style_preset=style_preset,
|
||||
quality_preset=quality_preset,
|
||||
nude_weight=nude_weight,
|
||||
**trace_policy.metadata_trace_fields(row, target=target),
|
||||
)
|
||||
return SDXLFormatRoute(
|
||||
output=output,
|
||||
branch=branch,
|
||||
method=method,
|
||||
target=target,
|
||||
style_preset=style_preset,
|
||||
quality_preset=quality_preset,
|
||||
nude_weight=nude_weight,
|
||||
)
|
||||
|
||||
|
||||
def format_sdxl_prompt(request: SDXLFormatRequest, deps: SDXLFormatDependencies) -> dict[str, str]:
|
||||
return format_sdxl_prompt_result(request, deps).output
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user