Compare commits
237 Commits
main
...
78e39734b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 78e39734b5 | |||
| 4c8edc0d3e | |||
| e2c4ecb853 | |||
| 3c54bb4bbe | |||
| 29efb954fb | |||
| 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.
|
||||
|
||||
@@ -22,6 +22,7 @@ The node is registered as:
|
||||
- `prompt_builder / SxCP Cast Control`
|
||||
- `prompt_builder / SxCP Cast Bias`
|
||||
- `prompt_builder / SxCP Generation Profile`
|
||||
- `prompt_builder / SxCP Style Pool`
|
||||
- `prompt_builder / SxCP Ethnicity List`
|
||||
- `prompt_builder / SxCP Hair Length`
|
||||
- `prompt_builder / SxCP Hair Color`
|
||||
@@ -38,6 +39,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,14 +82,23 @@ 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
|
||||
`theater_backstage` keep scene and framing compatible.
|
||||
- `SxCP Style Pool` outputs `style_config` for visual rendering style only.
|
||||
It can force realistic/photo/cinematic/comic output independently from
|
||||
category, action, pose, location, and camera. The previous colored-pencil
|
||||
comic wording is available as the `comic_pinup_colored_pencil` preset instead
|
||||
of being baked into hardcore pose prompts.
|
||||
- `SxCP Generation Profile` outputs `generation_profile` for common behavior
|
||||
presets such as casual-clean, evocative-softcore, hardcore-intense,
|
||||
Krea2-friendly, or Flux-original. Its clothing and pose overrides can be
|
||||
@@ -94,9 +121,33 @@ The practical compact workflow is:
|
||||
`Category Preset` + `Cast Control` + `Generation Profile` + optional
|
||||
`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control` or
|
||||
`Camera Orbit Control`, `Location Theme` or `Location Pool` + `Composition Pool`,
|
||||
`Woman Slot` / `Man Slot`, and `Character Profile`
|
||||
`Style Pool`, `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`.
|
||||
|
||||
Each layer can stay light on the main chain and take optional side-node inputs:
|
||||
`SxCP Scene Layer Seed Options`, `SxCP Scene Cast Options`,
|
||||
`SxCP Scene Character Options`, `SxCP Scene Wardrobe Options`,
|
||||
`SxCP Scene Location Layout Options`, `SxCP Scene Set Dressing Options`,
|
||||
`SxCP Scene Blocking Options`, `SxCP Scene Action Options`,
|
||||
`SxCP Scene Performance Options`, `SxCP Scene Camera Options`,
|
||||
`SxCP Scene Composition Options`, `SxCP Scene Lighting Options`, and
|
||||
`SxCP Scene Branch Options`. These side nodes are chainable and only override
|
||||
the layer they are connected to.
|
||||
|
||||
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:
|
||||
@@ -109,6 +160,14 @@ as one long chain:
|
||||
manually into either generation lane, but they are not part of the default
|
||||
main path.
|
||||
|
||||
A dedicated v2 scene-chain Insta/OF branching demo is available at
|
||||
`examples/scene_chain_insta_of_branching_workflow.json`.
|
||||
|
||||
A proposed adapter-style v2 layout is available at
|
||||
`examples/scene_chain_adapter_layout_workflow.json`. It keeps the main scene
|
||||
chain in one center lane and parks layer-specific option nodes beside the layer
|
||||
they override.
|
||||
|
||||
## Loop Nodes
|
||||
|
||||
`SxCP For Loop Start` and `SxCP For Loop End` provide a lightweight replacement
|
||||
@@ -331,11 +390,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 +472,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 +517,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.
|
||||
@@ -645,7 +740,7 @@ Example:
|
||||
"slug": "casual_clothes",
|
||||
"subject_type": "woman",
|
||||
"item_label": "Clothing",
|
||||
"style": "tasteful adult fashion-editorial coloured-pencil comic illustration",
|
||||
"style": "tasteful adult fashion-editorial scene",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "Streetwear",
|
||||
@@ -847,10 +942,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:
|
||||
|
||||
|
||||
+138
-3156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
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 = ""
|
||||
style_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 "",
|
||||
"style_config": request.style_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,292 @@
|
||||
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
|
||||
style_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,
|
||||
request.style_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
|
||||
@@ -7,8 +7,8 @@
|
||||
"weight": 1.0,
|
||||
"subject_type": "woman",
|
||||
"item_label": "Clothing",
|
||||
"style": "tasteful adult fashion-editorial coloured-pencil comic illustration with casual everyday styling",
|
||||
"positive_suffix": "Use crisp clean comic linework, soft fabric texture, detailed hatching, warm natural light, and tactile textured paper.",
|
||||
"style": "tasteful adult fashion-editorial scene with casual everyday styling",
|
||||
"positive_suffix": "Use readable full outfits, clear fabric texture, natural light, coherent anatomy, and polished styling detail.",
|
||||
"expression_pools": ["casual_observational_expressions"],
|
||||
"composition_pools": ["casual_fashion_compositions"],
|
||||
"subcategories": [
|
||||
@@ -833,8 +833,8 @@
|
||||
"weight": 1.0,
|
||||
"subject_type": "man",
|
||||
"item_label": "Clothing",
|
||||
"style": "tasteful adult menswear fashion-editorial coloured-pencil comic illustration with casual everyday styling",
|
||||
"positive_suffix": "Use crisp clean comic linework, structured fabric texture, detailed hatching, natural light, and tactile textured paper.",
|
||||
"style": "tasteful adult menswear fashion-editorial scene with casual everyday styling",
|
||||
"positive_suffix": "Use readable full outfits, structured fabric texture, natural light, coherent anatomy, and polished styling detail.",
|
||||
"expression_pools": ["men_casual_expressions"],
|
||||
"composition_pools": ["men_casual_compositions"],
|
||||
"subcategories": [
|
||||
@@ -1285,8 +1285,8 @@
|
||||
"weight": 1.0,
|
||||
"subject_type": "couple",
|
||||
"item_label": "Clothing",
|
||||
"style": "tasteful adult couple fashion-editorial coloured-pencil comic illustration with coordinated casual styling",
|
||||
"positive_suffix": "Use crisp clean comic linework, readable full outfits, detailed hatching, warm natural light, and tactile textured paper.",
|
||||
"style": "tasteful adult couple fashion-editorial scene with coordinated casual styling",
|
||||
"positive_suffix": "Use readable coordinated outfits, clear fabric texture, warm natural light, coherent body placement, and polished styling detail.",
|
||||
"expression_pools": ["couple_casual_expressions"],
|
||||
"composition_pools": ["couple_casual_compositions"],
|
||||
"subcategories": [
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"weight": 1.0,
|
||||
"subject_type": "woman",
|
||||
"item_label": "Erotic outfit",
|
||||
"style": "explicit adult erotic fashion illustration, sensual pin-up coloured-pencil comic style, adults only",
|
||||
"positive_suffix": "Use crisp clean comic linework, detailed hatching, soft skin shading, tactile fabric texture, warm intimate lighting, and textured paper.",
|
||||
"style": "explicit adult erotic fashion scene with sensual pin-up styling, adults only",
|
||||
"positive_suffix": "Use clear adult anatomy, readable erotic outfit construction, tactile fabric texture, warm intimate lighting, coherent body placement, and polished detail.",
|
||||
"negative_prompt": "minors, childlike appearance, schoolgirl, childlike costume, non-consensual, coercion, violence, injury, watermark",
|
||||
"scene_pools": ["softcore_creator_scenes", "mirror_scenes"],
|
||||
"expression_pools": ["softcore_creator_expressions", "erotic_inviting_expressions"],
|
||||
|
||||
+112
-36
@@ -7,14 +7,14 @@
|
||||
"weight": 1.0,
|
||||
"subject_type": "configured_cast",
|
||||
"item_label": "Sexual pose",
|
||||
"style": "explicit consensual adult hardcore sex illustration, anatomically clear erotic comic pin-up style, adults only",
|
||||
"positive_suffix": "Use clear adult anatomy, visible sexual contact, intense body language, crisp comic linework, detailed hatching, warm erotic lighting, and tactile textured paper.",
|
||||
"style": "explicit consensual adult hardcore sex scene, anatomically clear body positioning, adults only",
|
||||
"positive_suffix": "Use clear adult anatomy, visible sexual contact, readable limb placement, precise body orientation, coherent spatial depth, and intense body language.",
|
||||
"negative_prompt": "minors, childlike appearance, teen, schoolgirl, incest, bestiality, non-consensual, coercion, rape, violence, injury, blood, gore, watermark",
|
||||
"scene_pools": ["hardcore_private_scenes"],
|
||||
"expression_pools": ["hardcore_orgasm_expressions", "hardcore_messy_expressions"],
|
||||
"composition_pools": ["hardcore_explicit_compositions"],
|
||||
"prompt_template": "{subject_phrase}, all 21+ consenting adults: {style}. Cast: {cast_summary}. Role graph: {role_graph} Sexual pose: {item}. Setting: {scene}. Composition: {composition}. Facial expressions: {expression}. Make the scene explicit, hardcore, and anatomically clear, with visible genital contact and adult bodies only. {positive_suffix} Avoid: {negative_prompt}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, {item}, {scene}, {composition}, explicit consensual adult hardcore sex illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, {item}, {scene}, {composition}, explicit consensual adult hardcore sex scene",
|
||||
"expressions": [
|
||||
"adult ahegao-style orgasm face",
|
||||
"eyes rolled back with tongue out",
|
||||
@@ -92,17 +92,21 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use clear adult body contact, readable hands and faces, visible undressing, exposed skin, precise body orientation, and coherent spatial depth.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, foreplay teasing action: {item}, {scene}, {composition}, explicit consensual adult erotic foreplay illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, foreplay teasing action: {item}, {scene}, {composition}, explicit consensual adult erotic foreplay scene",
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_foreplay_expressions"],
|
||||
"composition_pools": ["foreplay_compositions"],
|
||||
"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,10 +215,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use clear adult manual contact, readable hands, explicit body positioning, exposed skin, precise hand placement, and coherent body orientation.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, manual stimulation action: {item}, {scene}, {composition}, explicit consensual adult manual stimulation illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, manual stimulation action: {item}, {scene}, {composition}, explicit consensual adult manual stimulation scene",
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_manual_expressions"],
|
||||
"composition_pools": ["manual_stimulation_compositions"],
|
||||
@@ -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,10 +324,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use readable adult body contact, hands and mouth on skin, exposed skin, clear head position, precise limb placement, and coherent body orientation.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, body worship action: {item}, {scene}, {composition}, explicit consensual adult body-contact illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, body worship action: {item}, {scene}, {composition}, explicit consensual adult body-contact scene",
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_interaction_expressions"],
|
||||
"composition_pools": ["interaction_compositions"],
|
||||
@@ -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,10 +437,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use readable adult movement, clothing being moved, hands guiding bodies, exposed skin, clear before-and-after body placement, and coherent spatial depth.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, clothing and position transition: {item}, {scene}, {composition}, explicit consensual adult transition illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, clothing and position transition: {item}, {scene}, {composition}, explicit consensual adult transition scene",
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_interaction_expressions"],
|
||||
"composition_pools": ["interaction_compositions"],
|
||||
@@ -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,10 +546,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use consensual adult control, readable hand placement, clear body positioning, exposed skin, precise contact points, and coherent body orientation.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, dominant guidance action: {item}, {scene}, {composition}, explicit consensual adult power-dynamic illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, dominant guidance action: {item}, {scene}, {composition}, explicit consensual adult power-dynamic scene",
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_interaction_expressions"],
|
||||
"composition_pools": ["interaction_compositions"],
|
||||
@@ -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,10 +660,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use creator-shot adult presentation, readable camera-facing pose, exposed skin, clear hand placement, stable camera geography, and coherent subject framing.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, camera performance action: {item}, {scene}, {composition}, explicit consensual adult creator-performance illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, camera performance action: {item}, {scene}, {composition}, explicit consensual adult creator-performance scene",
|
||||
"scene_pools": ["hardcore_private_scenes", "hardcore_bed_scenes", "hardcore_mirror_scenes"],
|
||||
"expression_pools": ["hardcore_interaction_expressions"],
|
||||
"composition_pools": ["camera_performance_compositions"],
|
||||
@@ -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,10 +768,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use readable adult group coordination, clear body spacing, visible watching/touching roles, exposed skin, precise role placement, and coherent spatial depth.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, group coordination action: {item}, {scene}, {composition}, explicit consensual adult group-interaction illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, group coordination action: {item}, {scene}, {composition}, explicit consensual adult group-interaction scene",
|
||||
"scene_pools": ["hardcore_group_scenes", "hardcore_private_scenes"],
|
||||
"expression_pools": ["hardcore_group_expressions"],
|
||||
"composition_pools": ["group_coordination_compositions"],
|
||||
@@ -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,10 +874,14 @@
|
||||
"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.",
|
||||
"positive_suffix": "Use adult post-sex intimacy, readable bodies and hands, visible aftermath details, clear body placement, and coherent spatial depth.",
|
||||
"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}.",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, aftercare and cleanup action: {item}, {scene}, {composition}, explicit consensual adult post-sex aftermath illustration",
|
||||
"caption_template": "{trigger}, {scene_kind}, {cast_summary}, {role_graph}, aftercare and cleanup action: {item}, {scene}, {composition}, explicit consensual adult post-sex aftermath scene",
|
||||
"scene_pools": ["hardcore_climax_scenes", "hardcore_bed_scenes", "hardcore_private_scenes"],
|
||||
"expression_pools": ["hardcore_aftercare_expressions"],
|
||||
"composition_pools": ["aftercare_compositions"],
|
||||
@@ -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.
|
||||
|
||||
+562
-151
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,960 @@
|
||||
{
|
||||
"last_node_id": 45,
|
||||
"last_link_id": 56,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "SxCPGlobalSeed",
|
||||
"pos": [-1900, -1040],
|
||||
"size": [300, 90],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "seed", "type": "INT", "links": null, "slot_index": 0},
|
||||
{"name": "seed_config", "type": "STRING", "links": [1], "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPGlobalSeed"},
|
||||
"widgets_values": [20260821]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "SxCPSceneStart",
|
||||
"pos": [-1260, -600],
|
||||
"size": [360, 250],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "seed_config", "type": "STRING", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [2], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneStart"},
|
||||
"widgets_values": [1, 41, 20260821, "raw", "provocative_erotic", "random", "balanced", "sxcppnl7", true]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SxCPSceneCastOptions",
|
||||
"pos": [-840, -1040],
|
||||
"size": [340, 180],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "cast_options", "type": "STRING", "links": [11], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCastOptions"},
|
||||
"widgets_values": ["replace", "mixed_couple", 1, 1, "woman_a", "none"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SxCPSceneCast",
|
||||
"pos": [-840, -600],
|
||||
"size": [360, 150],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 2},
|
||||
{"name": "cast_options", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [12], "slot_index": 0},
|
||||
{"name": "cast_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCast"},
|
||||
"widgets_values": ["mixed_couple", 1, 1, "woman_a", "none"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "SxCPSceneCharacterOptions",
|
||||
"pos": [-420, -1040],
|
||||
"size": [390, 260],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "character_options", "type": "STRING", "links": [14], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCharacterOptions"},
|
||||
"widgets_values": ["replace", "medium", "visible", "enabled", 0.45, 0.35, 0.85, "creator remains visually central"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SxCPSceneCharacter",
|
||||
"pos": [-420, -760],
|
||||
"size": [390, 360],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 12},
|
||||
{"name": "character_options", "type": "STRING", "link": 14},
|
||||
{"name": "seed_options", "type": "STRING", "link": 9}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [15], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "character_slot", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 3},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 4}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCharacter"},
|
||||
"widgets_values": [true, "woman", "A", -1, "25-year-old adult", "random", "random", "random", "medium", true, 0.45, "visible", -1.0, -1.0]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SxCPSceneCharacter",
|
||||
"pos": [-420, -310],
|
||||
"size": [390, 360],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 15},
|
||||
{"name": "seed_options", "type": "STRING", "link": 13}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [16], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "character_slot", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 3},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 4}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCharacter"},
|
||||
"widgets_values": [true, "man", "A", -1, "40-year-old adult", "random", "random", "average", "compact", true, 0.35, "visible", -1.0, -1.0]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SxCPSceneWardrobeOptions",
|
||||
"pos": [40, -1040],
|
||||
"size": [410, 320],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "wardrobe_options", "type": "STRING", "links": [18], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneWardrobeOptions"},
|
||||
"widgets_values": ["replace", "woman", "A", "minimal", "explicit_nude", true, "black lace lingerie set with stockings and garter details", "", "thin necklace, small earrings", "softcore outfit only; hardcore branch should not repeat outfit tokens"]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SxCPSceneWardrobe",
|
||||
"pos": [40, -760],
|
||||
"size": [390, 250],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 16},
|
||||
{"name": "wardrobe_options", "type": "STRING", "link": 18},
|
||||
{"name": "seed_options", "type": "STRING", "link": 17}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [19], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneWardrobe"},
|
||||
"widgets_values": [true, "woman", "A", "minimal", "black lace lingerie set with stockings and garter details", "fully nude", ""]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SxCPSceneWardrobeOptions",
|
||||
"pos": [40, 10],
|
||||
"size": [410, 290],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "wardrobe_options", "type": "STRING", "links": [20], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneWardrobeOptions"},
|
||||
"widgets_values": ["replace", "man", "A", "full", "dressed", true, "half-open black shirt with dark trousers", "shirt open, lower body mostly off-camera when explicit action is framed", "", "partner outfit stays simpler than creator outfit"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SxCPSceneWardrobe",
|
||||
"pos": [40, -360],
|
||||
"size": [390, 250],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 19},
|
||||
{"name": "wardrobe_options", "type": "STRING", "link": 20},
|
||||
{"name": "seed_options", "type": "STRING", "link": 21}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [22], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneWardrobe"},
|
||||
"widgets_values": [true, "man", "A", "full", "half-open black shirt with dark trousers", "shirt open, lower body mostly off-camera when explicit action is framed", ""]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "SxCPSceneLocationLayoutOptions",
|
||||
"pos": [500, -1040],
|
||||
"size": [410, 250],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "location_options", "type": "STRING", "links": [23], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLocationLayoutOptions"},
|
||||
"widgets_values": ["replace", "near desk edge, laptop corner, chair back", "warm work desks, laptop tables, glass partition seams", "repeated desk rows, plants, tall windows", "semi_public", "semi_public", "coworking lounge layout that survives camera rerolls"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "SxCPSceneLocation",
|
||||
"pos": [500, -760],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 22},
|
||||
{"name": "location_options", "type": "STRING", "link": 23},
|
||||
{"name": "seed_options", "type": "STRING", "link": 24}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [25], "slot_index": 0},
|
||||
{"name": "location_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLocation"},
|
||||
"widgets_values": [true, "replace", "custom_only", "coworking lounge with tall windows, warm desks, glass partitions, plants, and polished office-lounge surfaces", "shared location base"]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "SxCPSceneSetDressingOptions",
|
||||
"pos": [500, -170],
|
||||
"size": [410, 230],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "set_options", "type": "STRING", "links": [26], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneSetDressingOptions"},
|
||||
"widgets_values": ["replace", "laptop corner and polished tabletop line", "desks, chairs, glass seams, plant rows", "phone, coffee cup, folded jacket", "warm wood, glass reflections, muted office light", "keep props beside or behind the action, not blocking the subject"]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "SxCPSceneSetDressing",
|
||||
"pos": [500, -470],
|
||||
"size": [410, 250],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 25},
|
||||
{"name": "set_options", "type": "STRING", "link": 26}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [27], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneSetDressing"},
|
||||
"widgets_values": [true, "", "", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "SxCPSceneBlockingOptions",
|
||||
"pos": [960, -1040],
|
||||
"size": [410, 270],
|
||||
"flags": {},
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "blocking_options", "type": "STRING", "links": [28], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneBlockingOptions"},
|
||||
"widgets_values": ["replace", "custom", "woman close to the desk edge with room depth behind her", "man close enough for the hardcore branch but not forced into the softcore pose", "three_quarter", "foreground", "subjects dominate the frame while the coworking pattern remains visible", "shared blocking base before branch-specific action"]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "SxCPSceneBlocking",
|
||||
"pos": [960, -760],
|
||||
"size": [410, 250],
|
||||
"flags": {},
|
||||
"order": 16,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 27},
|
||||
{"name": "blocking_options", "type": "STRING", "link": 28}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [29], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneBlocking"},
|
||||
"widgets_values": [true, "custom", "", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "SxCPSceneActionOptions",
|
||||
"pos": [960, -170],
|
||||
"size": [410, 190],
|
||||
"flags": {},
|
||||
"order": 17,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "action_options", "type": "STRING", "links": [30], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneActionOptions"},
|
||||
"widgets_values": ["replace", "softcore", "softcore_tease", "no_change", "soft branch starts as an outfit tease; hardcore branch replaces action through the action filter"]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "SxCPSceneAction",
|
||||
"pos": [960, -470],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 18,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 29},
|
||||
{"name": "action_options", "type": "STRING", "link": 30},
|
||||
{"name": "seed_options", "type": "STRING", "link": 31}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [32], "slot_index": 0},
|
||||
{"name": "hardcore_position_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneAction"},
|
||||
"widgets_values": [true, "regular", "no_change", ""]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "SxCPScenePerformanceOptions",
|
||||
"pos": [1420, -1040],
|
||||
"size": [410, 250],
|
||||
"flags": {},
|
||||
"order": 19,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "performance_options", "type": "STRING", "links": [33], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPScenePerformanceOptions"},
|
||||
"widgets_values": ["replace", "enabled", "fixed", 0.55, "camera", "on_body", "posed", "controlled creator-camera expression"]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"type": "SxCPScenePerformance",
|
||||
"pos": [1420, -760],
|
||||
"size": [410, 180],
|
||||
"flags": {},
|
||||
"order": 20,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 32},
|
||||
{"name": "performance_options", "type": "STRING", "link": 33}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [34], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPScenePerformance"},
|
||||
"widgets_values": [true, "fixed", 0.55, ""]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"type": "SxCPSceneCameraOptions",
|
||||
"pos": [1420, -150],
|
||||
"size": [410, 170],
|
||||
"flags": {},
|
||||
"order": 21,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "camera_options", "type": "STRING", "links": [35], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCameraOptions"},
|
||||
"widgets_values": ["replace", "from_camera_config", true, "camera should adapt to the location layout instead of replacing it"]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"type": "SxCPSceneCamera",
|
||||
"pos": [1420, -530],
|
||||
"size": [410, 330],
|
||||
"flags": {},
|
||||
"order": 22,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 34},
|
||||
{"name": "camera_options", "type": "STRING", "link": 35}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [36], "slot_index": 0},
|
||||
{"name": "camera_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCamera"},
|
||||
"widgets_values": [true, "standard", "three_quarter", "eye_level", "auto", "auto", "vertical_story", "auto", "strong", "compact", ""]
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"type": "SxCPSceneCompositionOptions",
|
||||
"pos": [1880, -1040],
|
||||
"size": [410, 200],
|
||||
"flags": {},
|
||||
"order": 23,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "composition_options", "type": "STRING", "links": [37], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCompositionOptions"},
|
||||
"widgets_values": ["replace", "body", "three_quarter", "clear", "subject remains dominant while desk rows and glass seams prove the location"]
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"type": "SxCPSceneComposition",
|
||||
"pos": [1880, -760],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 24,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 36},
|
||||
{"name": "composition_options", "type": "STRING", "link": 37}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [38], "slot_index": 0},
|
||||
{"name": "composition_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneComposition"},
|
||||
"widgets_values": [true, "replace", "no_outfit_check", "", ""]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "SxCPSceneLightingOptions",
|
||||
"pos": [1880, -150],
|
||||
"size": [410, 220],
|
||||
"flags": {},
|
||||
"order": 25,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "lighting_options", "type": "STRING", "links": [39], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLightingOptions"},
|
||||
"widgets_values": ["replace", "window_light", "soft", "medium", "warm", "evening", "soft office-window light with practical lamps in the background"]
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"type": "SxCPSceneLighting",
|
||||
"pos": [1880, -470],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 26,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 38},
|
||||
{"name": "lighting_options", "type": "STRING", "link": 39}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [40], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLighting"},
|
||||
"widgets_values": [true, "auto", "auto", "auto", "auto", ""]
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"type": "SxCPSceneBranchOptions",
|
||||
"pos": [2340, -1040],
|
||||
"size": [390, 180],
|
||||
"flags": {},
|
||||
"order": 27,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "branch_options", "type": "STRING", "links": [41, 44, 47], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneBranchOptions"},
|
||||
"widgets_values": ["replace", "both", "same_creator_same_room", "hybrid", "same cast, same coworking room, different explicitness"]
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"type": "SxCPSceneBranchPair",
|
||||
"pos": [2340, -600],
|
||||
"size": [340, 130],
|
||||
"flags": {},
|
||||
"order": 28,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 40},
|
||||
{"name": "branch_options", "type": "STRING", "link": 41},
|
||||
{"name": "seed_options", "type": "STRING", "link": 42}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "softcore_scene", "type": "STRING", "links": [43], "slot_index": 0},
|
||||
{"name": "hardcore_scene", "type": "STRING", "links": [45], "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneBranchPair"},
|
||||
"widgets_values": ["same_creator_same_room", "hybrid"]
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"type": "SxCPSoftcoreBranchOptions",
|
||||
"pos": [2760, -760],
|
||||
"size": [390, 260],
|
||||
"flags": {},
|
||||
"order": 29,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 43},
|
||||
{"name": "branch_options", "type": "STRING", "link": 44}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [49], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSoftcoreBranchOptions"},
|
||||
"widgets_values": ["same_as_hardcore", "lingerie_tease", true, 0.45, "from_camera_config", "compact", ""]
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"type": "SxCPHardcoreActionFilter",
|
||||
"pos": [2340, -310],
|
||||
"size": [360, 300],
|
||||
"flags": {},
|
||||
"order": 30,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "hardcore_position_config", "type": "STRING", "links": [46], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPHardcoreActionFilter"},
|
||||
"widgets_values": ["penetration_only", false, false, true, false, false, false, false, false, false, false]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"type": "SxCPHardcoreBranchOptions",
|
||||
"pos": [2760, -350],
|
||||
"size": [390, 360],
|
||||
"flags": {},
|
||||
"order": 31,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 45},
|
||||
{"name": "hardcore_position_config", "type": "STRING", "link": 46},
|
||||
{"name": "branch_options", "type": "STRING", "link": 47},
|
||||
{"name": "seed_options", "type": "STRING", "link": 48}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [50], "slot_index": 0},
|
||||
{"name": "hardcore_position_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPHardcoreBranchOptions"},
|
||||
"widgets_values": ["couple", 1, 1, "hardcore", true, 0.85, "explicit_nude", "from_camera_config", "compact", "balanced", ""]
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"type": "SxCPScenePairOutput",
|
||||
"pos": [3220, -600],
|
||||
"size": [430, 290],
|
||||
"flags": {},
|
||||
"order": 32,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "softcore_scene", "type": "STRING", "link": 49},
|
||||
{"name": "hardcore_scene", "type": "STRING", "link": 50}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "softcore_prompt", "type": "STRING", "links": [53], "slot_index": 0},
|
||||
{"name": "hardcore_prompt", "type": "STRING", "links": [54], "slot_index": 1},
|
||||
{"name": "softcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "hardcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 3},
|
||||
{"name": "softcore_caption", "type": "STRING", "links": null, "slot_index": 4},
|
||||
{"name": "hardcore_caption", "type": "STRING", "links": null, "slot_index": 5},
|
||||
{"name": "shared_descriptor", "type": "STRING", "links": null, "slot_index": 6},
|
||||
{"name": "metadata_json", "type": "STRING", "links": [51, 52], "slot_index": 7},
|
||||
{"name": "scene_metadata_json", "type": "STRING", "links": null, "slot_index": 8}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPScenePairOutput"},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"type": "SxCPKrea2Formatter",
|
||||
"pos": [3720, -760],
|
||||
"size": [390, 270],
|
||||
"flags": {},
|
||||
"order": 33,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "metadata_json", "type": "STRING", "link": 51}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "krea_prompt", "type": "STRING", "links": null, "slot_index": 0},
|
||||
{"name": "negative_prompt", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "krea_softcore_prompt", "type": "STRING", "links": [55], "slot_index": 2},
|
||||
{"name": "krea_hardcore_prompt", "type": "STRING", "links": [56], "slot_index": 3},
|
||||
{"name": "softcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 4},
|
||||
{"name": "hardcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 5},
|
||||
{"name": "method", "type": "STRING", "links": null, "slot_index": 6},
|
||||
{"name": "route_trace_json", "type": "STRING", "links": null, "slot_index": 7}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPKrea2Formatter"},
|
||||
"widgets_values": ["", "metadata_json", "auto", "balanced", "preserve", false, "", ""]
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"type": "SxCPCaptionNaturalizer",
|
||||
"pos": [3720, -370],
|
||||
"size": [390, 240],
|
||||
"flags": {},
|
||||
"order": 34,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "metadata_json", "type": "STRING", "link": 52}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "natural_caption", "type": "STRING", "links": null, "slot_index": 0},
|
||||
{"name": "method", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "route_trace_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPCaptionNaturalizer"},
|
||||
"widgets_values": ["", "metadata_json", "training_dense", "balanced", "drop_style_tail", "sxcppnl7", true, "auto"]
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [4200, -900],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 35,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 53}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Raw softcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [4200, -690],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 36,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 54}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Raw hardcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [4200, -480],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 37,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 55}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Krea softcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [4200, -270],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 38,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 56}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Krea hardcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"type": "Note",
|
||||
"pos": [-1900, -470],
|
||||
"size": [560, 260],
|
||||
"flags": {},
|
||||
"order": 39,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Proposed adapter layout: the center row is the mental-model chain (scene start -> cast -> characters -> wardrobe -> location -> set -> blocking -> action -> performance -> camera -> composition -> lighting -> branch). Adapter nodes sit above/below the layer they override. The seed adapter bus on the left is chained once and reused by character, wardrobe, location, action, and hardcore branch nodes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"type": "SxCPSceneLayerSeedOptions",
|
||||
"pos": [-1900, -900],
|
||||
"size": [340, 170],
|
||||
"flags": {},
|
||||
"order": 40,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "seed_options", "type": "STRING", "links": [3], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLayerSeedOptions"},
|
||||
"widgets_values": ["character", "fixed", 20260831, "person", "same_for_all_rows", "replace_layer"]
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"type": "SxCPSceneLayerSeedOptions",
|
||||
"pos": [-1900, -710],
|
||||
"size": [340, 170],
|
||||
"flags": {},
|
||||
"order": 41,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "seed_options", "type": "STRING", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "seed_options", "type": "STRING", "links": [4], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLayerSeedOptions"},
|
||||
"widgets_values": ["wardrobe", "fixed", 20260832, "content", "same_for_all_rows", "add"]
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"type": "SxCPSceneLayerSeedOptions",
|
||||
"pos": [-1540, -900],
|
||||
"size": [340, 170],
|
||||
"flags": {},
|
||||
"order": 42,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "seed_options", "type": "STRING", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "seed_options", "type": "STRING", "links": [5], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLayerSeedOptions"},
|
||||
"widgets_values": ["location", "fixed", 20260833, "scene", "same_for_all_rows", "add"]
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"type": "SxCPSceneLayerSeedOptions",
|
||||
"pos": [-1540, -710],
|
||||
"size": [340, 170],
|
||||
"flags": {},
|
||||
"order": 43,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "seed_options", "type": "STRING", "link": 5}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "seed_options", "type": "STRING", "links": [6], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLayerSeedOptions"},
|
||||
"widgets_values": ["action", "fixed", 20260834, "pose", "same_for_all_rows", "add"]
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"type": "SxCPSceneLayerSeedOptions",
|
||||
"pos": [-1540, -520],
|
||||
"size": [340, 170],
|
||||
"flags": {},
|
||||
"order": 44,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "seed_options", "type": "STRING", "link": 6}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "seed_options", "type": "STRING", "links": [9, 13, 17, 21, 24, 31, 42, 48], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLayerSeedOptions"},
|
||||
"widgets_values": ["hardcore_branch", "fixed", 20260835, "pose", "same_for_all_rows", "add"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "STRING"],
|
||||
[2, 2, 0, 4, 0, "STRING"],
|
||||
[3, 41, 0, 42, 0, "STRING"],
|
||||
[4, 42, 0, 43, 0, "STRING"],
|
||||
[5, 43, 0, 44, 0, "STRING"],
|
||||
[6, 44, 0, 45, 0, "STRING"],
|
||||
[9, 45, 0, 6, 2, "STRING"],
|
||||
[11, 3, 0, 4, 1, "STRING"],
|
||||
[12, 4, 0, 6, 0, "STRING"],
|
||||
[13, 45, 0, 7, 1, "STRING"],
|
||||
[14, 5, 0, 6, 1, "STRING"],
|
||||
[15, 6, 0, 7, 0, "STRING"],
|
||||
[16, 7, 0, 9, 0, "STRING"],
|
||||
[17, 45, 0, 9, 2, "STRING"],
|
||||
[18, 8, 0, 9, 1, "STRING"],
|
||||
[19, 9, 0, 11, 0, "STRING"],
|
||||
[20, 10, 0, 11, 1, "STRING"],
|
||||
[21, 45, 0, 11, 2, "STRING"],
|
||||
[22, 11, 0, 13, 0, "STRING"],
|
||||
[23, 12, 0, 13, 1, "STRING"],
|
||||
[24, 45, 0, 13, 2, "STRING"],
|
||||
[25, 13, 0, 15, 0, "STRING"],
|
||||
[26, 14, 0, 15, 1, "STRING"],
|
||||
[27, 15, 0, 17, 0, "STRING"],
|
||||
[28, 16, 0, 17, 1, "STRING"],
|
||||
[29, 17, 0, 19, 0, "STRING"],
|
||||
[30, 18, 0, 19, 1, "STRING"],
|
||||
[31, 45, 0, 19, 2, "STRING"],
|
||||
[32, 19, 0, 21, 0, "STRING"],
|
||||
[33, 20, 0, 21, 1, "STRING"],
|
||||
[34, 21, 0, 23, 0, "STRING"],
|
||||
[35, 22, 0, 23, 1, "STRING"],
|
||||
[36, 23, 0, 25, 0, "STRING"],
|
||||
[37, 24, 0, 25, 1, "STRING"],
|
||||
[38, 25, 0, 27, 0, "STRING"],
|
||||
[39, 26, 0, 27, 1, "STRING"],
|
||||
[40, 27, 0, 29, 0, "STRING"],
|
||||
[41, 28, 0, 29, 1, "STRING"],
|
||||
[42, 45, 0, 29, 2, "STRING"],
|
||||
[43, 29, 0, 30, 0, "STRING"],
|
||||
[44, 28, 0, 30, 1, "STRING"],
|
||||
[45, 29, 1, 32, 0, "STRING"],
|
||||
[46, 31, 0, 32, 1, "STRING"],
|
||||
[47, 28, 0, 32, 2, "STRING"],
|
||||
[48, 45, 0, 32, 3, "STRING"],
|
||||
[49, 30, 0, 33, 0, "STRING"],
|
||||
[50, 32, 0, 33, 1, "STRING"],
|
||||
[51, 33, 7, 34, 0, "STRING"],
|
||||
[52, 33, 7, 35, 0, "STRING"],
|
||||
[53, 33, 0, 36, 0, "STRING"],
|
||||
[54, 33, 1, 37, 0, "STRING"],
|
||||
[55, 34, 2, 38, 0, "STRING"],
|
||||
[56, 34, 3, 39, 0, "STRING"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Seed Adapter Bus",
|
||||
"bounding": [-1930, -1090, 760, 780],
|
||||
"color": "#4d6b8f",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Main Scene Chain",
|
||||
"bounding": [-1290, -810, 3610, 900],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Layer Option Adapters",
|
||||
"bounding": [-870, -1090, 3220, 1180],
|
||||
"color": "#6b5a8f",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Softcore/Hardcore Branch Adapters",
|
||||
"bounding": [2310, -1090, 880, 1160],
|
||||
"color": "#8a5a5a",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Pair Output, Formatters, Persistent Text Previews",
|
||||
"bounding": [3190, -950, 1470, 900],
|
||||
"color": "#4d7f45",
|
||||
"font_size": 24
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.52,
|
||||
"offset": [1290, 665]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
{
|
||||
"last_node_id": 25,
|
||||
"last_link_id": 24,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "SxCPGlobalSeed",
|
||||
"pos": [-1900, -760],
|
||||
"size": [300, 90],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "seed", "type": "INT", "links": null, "slot_index": 0},
|
||||
{"name": "seed_config", "type": "STRING", "links": [1], "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPGlobalSeed"},
|
||||
"widgets_values": [20260801]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "SxCPSceneStart",
|
||||
"pos": [-1540, -840],
|
||||
"size": [360, 250],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "seed_config", "type": "STRING", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [2], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneStart"},
|
||||
"widgets_values": [1, 41, 20260801, "raw", "provocative_erotic", "random", "balanced", "sxcppnl7", true]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SxCPSceneCast",
|
||||
"pos": [-1540, -520],
|
||||
"size": [360, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [3], "slot_index": 0},
|
||||
{"name": "cast_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCast"},
|
||||
"widgets_values": ["mixed_couple", 1, 1, "woman_a", "none"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "SxCPSceneCharacter",
|
||||
"pos": [-1120, -860],
|
||||
"size": [390, 360],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 3}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [4], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "character_slot", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 3},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 4}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCharacter"},
|
||||
"widgets_values": [true, "woman", "A", -1, "25-year-old adult", "random", "random", "random", "medium", true, 0.45, "visible", -1.0, -1.0]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "SxCPSceneCharacter",
|
||||
"pos": [-1120, -430],
|
||||
"size": [390, 360],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 4}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [5], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "character_slot", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 3},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 4}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCharacter"},
|
||||
"widgets_values": [true, "man", "A", -1, "40-year-old adult", "random", "random", "average", "compact", true, 0.35, "visible", -1.0, -1.0]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SxCPSceneWardrobe",
|
||||
"pos": [-670, -860],
|
||||
"size": [390, 250],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 5}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [6], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneWardrobe"},
|
||||
"widgets_values": [true, "woman", "A", "minimal", "black lace lingerie set with stockings and garter details", "fully nude", "base outfit continuity for the creator"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SxCPSceneWardrobe",
|
||||
"pos": [-670, -500],
|
||||
"size": [390, 250],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 6}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [7], "slot_index": 0},
|
||||
{"name": "character_cast", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneWardrobe"},
|
||||
"widgets_values": [true, "man", "A", "full", "half-open black shirt with dark trousers", "shirt open, lower body mostly off-camera when explicit action is framed", "partner outfit continuity"]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SxCPSceneLocation",
|
||||
"pos": [-220, -860],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 7}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [8], "slot_index": 0},
|
||||
{"name": "location_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLocation"},
|
||||
"widgets_values": [true, "replace", "custom_only", "private creator bedroom with bed, mirror, phone tripod, warm lamps, and visible content setup", "same room shared by both branches"]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SxCPSceneSetDressing",
|
||||
"pos": [-220, -570],
|
||||
"size": [410, 250],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 8}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [9], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneSetDressing"},
|
||||
"widgets_values": [true, "bed edge, mirror frame, phone tripod", "warm lamps, curtains, rumpled bedding", "phone stand, folded clothes nearby", "creator-room set remains readable without forcing camera phrasing"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SxCPSceneBlocking",
|
||||
"pos": [250, -860],
|
||||
"size": [410, 250],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 9}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [10], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneBlocking"},
|
||||
"widgets_values": [true, "custom", "woman near the bed and mirror setup", "man close enough for the hardcore branch but not forced into the softcore pose", "shared blocking base for a soft tease or explicit branch"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SxCPSceneCamera",
|
||||
"pos": [250, -500],
|
||||
"size": [410, 330],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 10}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [11], "slot_index": 0},
|
||||
{"name": "camera_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneCamera"},
|
||||
"widgets_values": [true, "standard", "three_quarter", "eye_level", "auto", "auto", "vertical_story", "auto", "strong", "compact", ""]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "SxCPSceneComposition",
|
||||
"pos": [720, -860],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 11}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [12], "slot_index": 0},
|
||||
{"name": "composition_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneComposition"},
|
||||
"widgets_values": [true, "replace", "no_outfit_check", "vertical creator-frame with body and room setup readable", ""]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "SxCPSceneLighting",
|
||||
"pos": [720, -570],
|
||||
"size": [410, 210],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 12}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [13], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneLighting"},
|
||||
"widgets_values": [true, "practical_lamps", "soft", "medium", "warm", ""]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "SxCPSceneBranchPair",
|
||||
"pos": [1190, -720],
|
||||
"size": [340, 120],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 13}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "softcore_scene", "type": "STRING", "links": [14], "slot_index": 0},
|
||||
{"name": "hardcore_scene", "type": "STRING", "links": [15], "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSceneBranchPair"},
|
||||
"widgets_values": ["same_creator_same_room", "hybrid"]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "SxCPSoftcoreBranchOptions",
|
||||
"pos": [1580, -860],
|
||||
"size": [390, 260],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 14}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [17], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPSoftcoreBranchOptions"},
|
||||
"widgets_values": ["same_as_hardcore", "lingerie_tease", true, 0.45, "from_camera_config", "compact", ""]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "SxCPHardcoreActionFilter",
|
||||
"pos": [1190, -470],
|
||||
"size": [360, 300],
|
||||
"flags": {},
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "hardcore_position_config", "type": "STRING", "links": [16], "slot_index": 0},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 1}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPHardcoreActionFilter"},
|
||||
"widgets_values": ["penetration_only", false, false, true, false, false, false, false, false, false, false]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "SxCPHardcoreBranchOptions",
|
||||
"pos": [1580, -480],
|
||||
"size": [390, 360],
|
||||
"flags": {},
|
||||
"order": 16,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "scene", "type": "STRING", "link": 15},
|
||||
{"name": "hardcore_position_config", "type": "STRING", "link": 16}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "scene", "type": "STRING", "links": [18], "slot_index": 0},
|
||||
{"name": "hardcore_position_config", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "summary", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "metadata_json", "type": "STRING", "links": null, "slot_index": 3}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPHardcoreBranchOptions"},
|
||||
"widgets_values": ["couple", 1, 1, "hardcore", true, 0.85, "explicit_nude", "from_camera_config", "compact", "balanced", ""]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "SxCPScenePairOutput",
|
||||
"pos": [2050, -720],
|
||||
"size": [430, 290],
|
||||
"flags": {},
|
||||
"order": 17,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "softcore_scene", "type": "STRING", "link": 17},
|
||||
{"name": "hardcore_scene", "type": "STRING", "link": 18}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "softcore_prompt", "type": "STRING", "links": [21], "slot_index": 0},
|
||||
{"name": "hardcore_prompt", "type": "STRING", "links": [22], "slot_index": 1},
|
||||
{"name": "softcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 2},
|
||||
{"name": "hardcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 3},
|
||||
{"name": "softcore_caption", "type": "STRING", "links": null, "slot_index": 4},
|
||||
{"name": "hardcore_caption", "type": "STRING", "links": null, "slot_index": 5},
|
||||
{"name": "shared_descriptor", "type": "STRING", "links": null, "slot_index": 6},
|
||||
{"name": "metadata_json", "type": "STRING", "links": [19, 20], "slot_index": 7},
|
||||
{"name": "scene_metadata_json", "type": "STRING", "links": null, "slot_index": 8}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPScenePairOutput"},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "SxCPKrea2Formatter",
|
||||
"pos": [2550, -820],
|
||||
"size": [390, 270],
|
||||
"flags": {},
|
||||
"order": 18,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "metadata_json", "type": "STRING", "link": 19}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "krea_prompt", "type": "STRING", "links": null, "slot_index": 0},
|
||||
{"name": "negative_prompt", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "krea_softcore_prompt", "type": "STRING", "links": [23], "slot_index": 2},
|
||||
{"name": "krea_hardcore_prompt", "type": "STRING", "links": [24], "slot_index": 3},
|
||||
{"name": "softcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 4},
|
||||
{"name": "hardcore_negative_prompt", "type": "STRING", "links": null, "slot_index": 5},
|
||||
{"name": "method", "type": "STRING", "links": null, "slot_index": 6},
|
||||
{"name": "route_trace_json", "type": "STRING", "links": null, "slot_index": 7}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPKrea2Formatter"},
|
||||
"widgets_values": ["", "metadata_json", "auto", "balanced", "preserve", false, "", ""]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "SxCPCaptionNaturalizer",
|
||||
"pos": [2550, -450],
|
||||
"size": [390, 240],
|
||||
"flags": {},
|
||||
"order": 19,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "metadata_json", "type": "STRING", "link": 20}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "natural_caption", "type": "STRING", "links": null, "slot_index": 0},
|
||||
{"name": "method", "type": "STRING", "links": null, "slot_index": 1},
|
||||
{"name": "route_trace_json", "type": "STRING", "links": null, "slot_index": 2}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPCaptionNaturalizer"},
|
||||
"widgets_values": ["", "metadata_json", "training_dense", "balanced", "drop_style_tail", "sxcppnl7", true, "auto"]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [3050, -920],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 20,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 21}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Raw softcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [3050, -710],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 21,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 22}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Raw hardcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [3050, -500],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 22,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 23}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Krea softcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"type": "SxCPPreviewAnyAsText",
|
||||
"pos": [3050, -290],
|
||||
"size": [420, 180],
|
||||
"flags": {},
|
||||
"order": 23,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "*", "link": 24}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "text", "type": "STRING", "links": null, "slot_index": 0}
|
||||
],
|
||||
"properties": {"Node name for S&R": "SxCPPreviewAnyAsText"},
|
||||
"widgets_values": ["Krea hardcore prompt preview", "auto", 30000]
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"type": "Note",
|
||||
"pos": [-1900, -560],
|
||||
"size": [520, 210],
|
||||
"flags": {},
|
||||
"order": 24,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Scene-chain Insta/OF demo: build one shared scene, split it with Scene Branch Pair, then refine softcore and hardcore separately before Scene Pair Output. Change pose through Hardcore Action Filter or the seed_config pose/role axes."
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "STRING"],
|
||||
[2, 2, 0, 3, 0, "STRING"],
|
||||
[3, 3, 0, 4, 0, "STRING"],
|
||||
[4, 4, 0, 5, 0, "STRING"],
|
||||
[5, 5, 0, 6, 0, "STRING"],
|
||||
[6, 6, 0, 7, 0, "STRING"],
|
||||
[7, 7, 0, 8, 0, "STRING"],
|
||||
[8, 8, 0, 9, 0, "STRING"],
|
||||
[9, 9, 0, 10, 0, "STRING"],
|
||||
[10, 10, 0, 11, 0, "STRING"],
|
||||
[11, 11, 0, 12, 0, "STRING"],
|
||||
[12, 12, 0, 13, 0, "STRING"],
|
||||
[13, 13, 0, 14, 0, "STRING"],
|
||||
[14, 14, 0, 15, 0, "STRING"],
|
||||
[15, 14, 1, 17, 0, "STRING"],
|
||||
[16, 16, 0, 17, 1, "STRING"],
|
||||
[17, 15, 0, 18, 0, "STRING"],
|
||||
[18, 17, 0, 18, 1, "STRING"],
|
||||
[19, 18, 7, 19, 0, "STRING"],
|
||||
[20, 18, 7, 20, 0, "STRING"],
|
||||
[21, 18, 0, 21, 0, "STRING"],
|
||||
[22, 18, 1, 22, 0, "STRING"],
|
||||
[23, 19, 2, 23, 0, "STRING"],
|
||||
[24, 19, 3, 24, 0, "STRING"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Shared scene setup",
|
||||
"bounding": [-1940, -920, 3100, 820],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Softcore/hardcore branch split",
|
||||
"bounding": [1160, -910, 860, 820],
|
||||
"color": "#5f4d8f",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Pair output, formatters, previews",
|
||||
"bounding": [2020, -960, 1490, 880],
|
||||
"color": "#4d7f45",
|
||||
"font_size": 24
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.62,
|
||||
"offset": [1320, 670]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -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
|
||||
+328
-2380
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())
|
||||
|
||||
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
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_STYLE_CONFIG = "SXCP_STYLE_CONFIG"
|
||||
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,),
|
||||
"style_config": (SXCP_STYLE_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="",
|
||||
style_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 "",
|
||||
style_config=style_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,),
|
||||
"style_config": (SXCP_STYLE_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="",
|
||||
style_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 "",
|
||||
style_config=style_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",
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
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"
|
||||
SXCP_STYLE_CONFIG = "SXCP_STYLE_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,),
|
||||
"style_config": (SXCP_STYLE_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="",
|
||||
style_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 "",
|
||||
style_config=style_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,385 @@
|
||||
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,
|
||||
)
|
||||
from .style_config import (
|
||||
build_style_config_json,
|
||||
style_combine_mode_choices,
|
||||
style_pool_preset_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,
|
||||
)
|
||||
from style_config import (
|
||||
build_style_config_json,
|
||||
style_combine_mode_choices,
|
||||
style_pool_preset_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"
|
||||
SXCP_STYLE_CONFIG = "SXCP_STYLE_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 SxCPStylePool:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enabled": ("BOOLEAN", {"default": True}),
|
||||
"combine_mode": (style_combine_mode_choices(), {"default": "replace"}),
|
||||
"preset": (style_pool_preset_choices(), {"default": "realistic_photo"}),
|
||||
"custom_style": ("STRING", {"default": "", "multiline": True}),
|
||||
"custom_positive_suffix": ("STRING", {"default": "", "multiline": True}),
|
||||
"custom_negative": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"style_config": (SXCP_STYLE_CONFIG,),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (SXCP_STYLE_CONFIG, "STRING")
|
||||
RETURN_NAMES = ("style_config", "summary")
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "prompt_builder"
|
||||
|
||||
def build(
|
||||
self,
|
||||
enabled,
|
||||
combine_mode,
|
||||
preset,
|
||||
custom_style,
|
||||
custom_positive_suffix,
|
||||
custom_negative,
|
||||
style_config="",
|
||||
):
|
||||
config = build_style_config_json(
|
||||
enabled=enabled,
|
||||
combine_mode=combine_mode,
|
||||
preset=preset,
|
||||
custom_style=custom_style or "",
|
||||
custom_positive_suffix=custom_positive_suffix or "",
|
||||
custom_negative=custom_negative or "",
|
||||
style_config=style_config or "",
|
||||
)
|
||||
parsed = json.loads(config)
|
||||
return config, parsed.get("summary", "")
|
||||
|
||||
|
||||
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,
|
||||
"SxCPStylePool": SxCPStylePool,
|
||||
"SxCPCastControl": SxCPCastControl,
|
||||
"SxCPCastBias": SxCPCastBias,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SxCPCategoryPreset": "SxCP Category Preset",
|
||||
"SxCPLocationPool": "SxCP Location Pool",
|
||||
"SxCPCompositionPool": "SxCP Composition Pool",
|
||||
"SxCPLocationTheme": "SxCP Location Theme",
|
||||
"SxCPStylePool": "SxCP Style Pool",
|
||||
"SxCPCastControl": "SxCP Cast Control",
|
||||
"SxCPCastBias": "SxCP Cast Bias",
|
||||
}
|
||||
+2382
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,509 @@
|
||||
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.",
|
||||
"layer": "Scene layer affected by this side node. all applies to every compatible scene node that receives the options.",
|
||||
"seed_mode": "follow_global uses the scene seed, fixed uses the seed field, random resolves a fresh seed at queue time, disabled does nothing.",
|
||||
"row_behavior": "same_for_all_rows keeps the option seed as-is; vary_by_row offsets it by row number before writing axis seeds.",
|
||||
"reroll_axis": "Specific generator axis group to reroll. none uses the default axes for the selected scene layer.",
|
||||
"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.",
|
||||
"style_config": "Visual style config from SxCP Style Pool. It controls realistic/photo/comic rendering separately from category, action, and pose logic.",
|
||||
"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.",
|
||||
"custom_style": "Manual visual style phrase. Use this when the preset list is not specific enough.",
|
||||
"custom_positive_suffix": "Manual style/quality suffix merged with the selected style preset.",
|
||||
"custom_negative": "Negative style terms added by the style pool.",
|
||||
"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.",
|
||||
"options": "Incoming options of the same type. Chain option nodes with combine_mode=add when multiple side knobs should contribute.",
|
||||
"seed_options": "Scene layer seed options. Connect Scene Layer Seed Options to reroll one layer without changing the whole scene.",
|
||||
"cast_options": "Optional cast side-node settings that override the Cast node widgets only when connected.",
|
||||
"character_options": "Optional character side-node settings that override descriptor, presence, and expression controls.",
|
||||
"wardrobe_options": "Optional wardrobe side-node settings for subject-specific clothing, nudity state, and wardrobe prompt text.",
|
||||
"location_options": "Optional location layout settings such as foreground anchors, midground, repetition, and public/private context.",
|
||||
"set_options": "Optional set-dressing settings for props, repeated background, foreground anchors, and sensory details.",
|
||||
"blocking_options": "Optional blocking settings for subject placement, orientation, depth plane, and exact body geography.",
|
||||
"action_options": "Optional action settings for scene kind, action family, category preset, and manual action text.",
|
||||
"performance_options": "Optional performance settings for expression, gaze, hands, body tension, and actor notes.",
|
||||
"camera_options": "Optional camera side-node settings that describe camera source and freeform camera text.",
|
||||
"composition_options": "Optional composition side-node settings for readability target, crop, occlusion, and framing text.",
|
||||
"lighting_options": "Optional lighting side-node settings for source, softness, contrast, color, and time of day.",
|
||||
"branch_options": "Optional branch settings that apply to softcore, hardcore, or both Insta/OF branches.",
|
||||
"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.",
|
||||
"midground_layer": "Readable middle-distance scene elements between the subject and background.",
|
||||
"background_repetition": "Repeated environmental structure that helps the model keep a location coherent across rerolls.",
|
||||
"visibility_level": "How visible or hidden the scene should feel inside the location.",
|
||||
"public_level": "Private, semi-public, or public context for the location layer.",
|
||||
"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.",
|
||||
"sensory_details": "Small material/light/surface details that make the set dressing feel specific.",
|
||||
"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.",
|
||||
"body_orientation": "Front, side, back, three-quarter, or POV-facing body orientation.",
|
||||
"depth_plane": "Whether the subjects sit in foreground, midground, background, or a layered composition.",
|
||||
"distance_note": "Extra spatial distance wording, such as close together, across the table, or partly hidden behind a shelf.",
|
||||
"custom_blocking": "Exact blocking/positioning sentence for the scene layer.",
|
||||
"scene_kind": "Regular, softcore, or hardcore intent for this action layer.",
|
||||
"action_family": "Broad action family such as softcore tease, oral, penetration, climax, group, or custom.",
|
||||
"action_prompt": "Action text stored separately from blocking and camera. Use position pools for hardcore randomization when possible.",
|
||||
"gaze": "Where the character looks: camera, partner, down, away, over shoulder, or eyes closed.",
|
||||
"hand_placement": "What hands are doing: relaxed, on body, on partner, holding camera, pulling clothing, or braced.",
|
||||
"body_tension": "Body performance cue: relaxed, posed, arched, braced, or active motion.",
|
||||
"performance_prompt": "Expression, gaze, hand, and body-performance note stored separately from the action.",
|
||||
"camera_source": "Where camera text comes from conceptually: config, qwen orbit, POV, phone, external, or manual.",
|
||||
"preserve_location_layout": "Keep location layout wording compatible with the camera instead of letting camera text replace the space.",
|
||||
"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.",
|
||||
"readability_target": "What the composition should keep most readable: face, body, action, room, anchor objects, or contact points.",
|
||||
"crop": "Composition crop intent such as full body, three-quarter, close-up, or extreme close-up.",
|
||||
"occlusion": "How much foreground or hidden-sightline occlusion the composition should allow.",
|
||||
"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.",
|
||||
"time_of_day": "Optional time-of-day lighting context.",
|
||||
"custom_lighting": "Exact lighting sentence for the scene layer.",
|
||||
"branch_target": "Whether branch options affect both Insta/OF branches, softcore only, or hardcore only.",
|
||||
"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.",
|
||||
"wardrobe_state": "High-level clothing/body-exposure state. explicit_nude avoids conflicting outfit text in hardcore prompts.",
|
||||
"accessories": "Accessories that can remain visible without forcing full outfit wording.",
|
||||
"avoid_clothing_when_nude": "When nude states are selected, avoid reintroducing clothing words that make the image model dress the subject.",
|
||||
"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.",
|
||||
},
|
||||
"SxCPStylePool": {
|
||||
"enabled": "Disable to keep the node wired while preserving category/default style behavior.",
|
||||
"combine_mode": "replace overrides category style; add appends this visual style to incoming/category style; disabled emits no style override.",
|
||||
"preset": "Visual rendering preset only. It does not select content, pose, exposure, or camera.",
|
||||
"style_config": "Optional incoming style config. Use combine_mode=add to chain multiple style nodes.",
|
||||
"custom_style": "Manual visual style phrase, for example realistic phone photo or colored-pencil pin-up.",
|
||||
"custom_positive_suffix": "Extra rendering/detail sentence added to the prompt when the style is active.",
|
||||
"custom_negative": "Negative style terms merged into the generated negative prompt.",
|
||||
},
|
||||
"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))
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
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 = ""
|
||||
style_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 "",
|
||||
style_config=request.style_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)
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
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],
|
||||
style_config: str | dict[str, Any] | None = "",
|
||||
) -> 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 "",
|
||||
style_config=style_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 "",
|
||||
style_config=style_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],
|
||||
style_config: str | dict[str, Any] | None = "",
|
||||
) -> 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,
|
||||
style_config=style_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)
|
||||
+1437
-7448
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,149 @@
|
||||
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
|
||||
from . import style_config as style_config_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
|
||||
import style_config as style_config_policy
|
||||
|
||||
|
||||
GENERIC_POSITIVE_SUFFIX = (
|
||||
"Use coherent anatomy, readable body placement, natural light response, "
|
||||
"clear material texture, stable spatial depth, and polished visual detail."
|
||||
)
|
||||
|
||||
DEFAULT_STYLE = "realistic adult scene with natural camera realism"
|
||||
|
||||
|
||||
@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}"
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
style_config: str | dict[str, Any] | None = None,
|
||||
) -> RowTextFields:
|
||||
base_negative = str(category_policy.merged_field(category, subcategory, item, "negative_prompt", g.NEGATIVE_PROMPT))
|
||||
base_suffix = str(category_policy.merged_field(category, subcategory, item, "positive_suffix", GENERIC_POSITIVE_SUFFIX))
|
||||
base_style = str(category_policy.merged_field(category, subcategory, item, "style", DEFAULT_STYLE))
|
||||
style, positive_suffix = style_config_policy.resolve_style_fields(base_style, base_suffix, style_config)
|
||||
return RowTextFields(
|
||||
negative_prompt=style_config_policy.merge_negative_prompt(base_negative, style_config),
|
||||
positive_suffix=positive_suffix,
|
||||
style=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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user