Body Parts Library
Body Parts Library
Section titled “Body Parts Library”A catalogue of reference body-part images that generation flows draw on when they need anatomy guidance. Admins upload and tag the images in Fennec; two very different consumers read them back.
The vocabulary used to tag an image — which body-part slots exist, which filters each slot accepts, and which values each filter allows — is configuration, not code. Adding a slot or a filter value is an application-settings edit and needs no deploy.
Data model
Section titled “Data model”One table, body_reference_library_item, defined in apps/brain/prisma/schema.prisma:
| Column | Meaning |
|---|---|
path |
Object-storage key, written under the body-parts-library/ prefix. |
body_part |
Slot key, for example bust or pussy_closeup. |
attributes |
JSON map of filter key to value, or NULL. |
usage |
Always NUDIFY_REFERENCE; the column predates the second consumer and is not currently branched on. |
There is no foreign key to the taxonomy. A row keeps whatever slot and attributes it was uploaded with, so removing a value from the configuration does not invalidate rows already using it — see Compatibility hazards.
The taxonomy setting
Section titled “The taxonomy setting”The vocabulary lives in the body-parts-library-taxonomy application-settings
entry. Filters are declared once in a registry and referenced by key from each
slot, so a filter shared by several slots — skin_tone is on all of them — cannot
drift between them.
{ "filters": { "size": { "label": "Size", "values": [{ "value": "huge", "label": "Huge" }] }, "style": { "label": "Style", "values": [{ "value": "shaved", "label": "Shaved" }] }, "skin_tone": { "label": "Skin tone", "values": [{ "value": "pale", "label": "Pale" }] } }, "slots": [ { "key": "bust", "label": "Bust", "filters": ["size", "style", "skin_tone"] }, { "key": "penis_closeup", "label": "Penis closeup", "filters": ["size", "skin_tone"] } ]}label on a filter value is optional; consumers fall back to the raw value.
The Zod schema in application-settings.schemas.ts enforces two invariants beyond
the shape: every filter key a slot references must exist in filters, and slot
keys must be unique. Slot keys are restricted to ^[a-z0-9_]+$ so they are safe
as query values and JSON keys.
Seeded slots are bust, genitalia, pussy_closeup, anus_closeup and
penis_closeup, with filters size, style and skin_tone. The seed lives in
the migration 20260908150000_seed_body_parts_library_taxonomy and is applied
with ON CONFLICT DO NOTHING, so it establishes the initial row and never
overwrites later edits.
Changing it
Section titled “Changing it”Edit the entry on Fennec’s Application Settings page, or PUT /application-settings/body-parts-library-taxonomy. Writes are validated against
the schema and recorded in the audit log. Reads come from a process-local cache
that refreshes every five minutes (SETTINGS_CACHE_TIME_SECONDS), so a change
takes up to that long to reach the upload form and the workflow editor.
Where the data is validated, and where it is not
Section titled “Where the data is validated, and where it is not”ApplicationSettingsService.loadSettings deliberately does not validate the
rows it reads; it freezes them as they came out of the database. Validation
therefore happens at two points instead:
- on write, through
PUT /application-settings/:id; - on read, in
BodyPartsLibraryService.getTaxonomy(), which parses the row rather than casting it.
The read-side parse is what makes the schema’s cross-reference invariant
trustworthy for consumers, which index filters[key] directly. A row written
outside the API — by a migration, or by hand in the database — reaches consumers
unchecked otherwise.
Matching: an absent attribute is a wildcard
Section titled “Matching: an absent attribute is a wildcard”This is the rule that most surprises people reading a query result.
When a consumer asks for images matching { "skin_tone": "pale" }, a row that has
no skin_tone key matches. An absent attribute means “universal”, not
“unknown”: the image is treated as usable for every value of that filter. Leaving
a filter unset at upload time is therefore a deliberate act, and the Fennec upload
form spells it as Universal.
The practical consequence: “all pale images” really means “images tagged pale,
plus images with no skin tone at all”. A pool can be wider than the query reads.
Hard validation follows directly from this rule rather than from general caution.
Under wildcard matching an unknown filter key narrows nothing — no row has it, so
every row matches — which would turn a typo into a silent change of behaviour
instead of an error. So slot keys, filter keys and filter values are rejected
outright, both on upload and at node execution, by assertValidSelection in
body-parts-taxonomy.ts.
Consumers
Section titled “Consumers”flowchart TD
S["body-parts-library-taxonomy<br/>(application settings)"]
F["Fennec upload form"]
T["body_reference_library_item"]
N["T-Bone node<br/>character:pick_stock_body_reference"]
L["NudifyService<br/>(legacy)"]
S -->|slots + filters| F
S -->|live enum, validation| N
F -->|validated upload| T
T -->|wildcard match,<br/>deterministic pick| N
T -->|strict match,<br/>ORDER BY RANDOM| L
The two consumers do not share matching semantics. That is deliberate, and it is the first thing to check when a result looks wrong.
T-Bone workflow node
Section titled “T-Bone workflow node”character:pick_stock_body_reference takes a required body_part and optional
attributes, validates both against the taxonomy, and picks one matching image.
Selection is deterministic, not random: the node hashes
executionId + nodeId + bodyPart and indexes into the candidate pool sorted by
id. One execution always resolves to the same image, so a retry or a replay
reproduces the run. An empty pool fails the execution rather than falling back.
The node serves its body_part options to the workflow editor as a live enum.
Node metadata is captured once at startup, so the node implements the optional
resolveInputSchema() hook on NodeHandlerRegistry, which the catalogue endpoint
calls per request. The slot dropdown in Flank therefore follows the setting
without a Brain restart. If the taxonomy is missing or malformed the hook falls
back to the static schema, so one bad settings row cannot take down the whole
node palette.
Legacy reference nudify
Section titled “Legacy reference nudify”NudifyService backs POST /api/onboarding/reference-nudify and predates the
taxonomy. It queries through findRandomByBodyPartAndAttribute, which is raw SQL
with strict equality on a single attribute and ORDER BY RANDOM(). It asks
for bust by size and genitalia by style, with both slot names and both
filter keys hardcoded.
Wildcard matching does not apply on this path. A row with no size is invisible
to it, where the workflow node would happily select the same row.
See Character Creation for where this sits in the onboarding flow, and Brain API — Reference Nudify for the endpoint contract.
Admin workflow
Section titled “Admin workflow”Adding vocabulary, no deploy required:
- Edit
body-parts-library-taxonomyon Fennec’s Application Settings page. - Wait out the settings cache (up to five minutes).
- The new slot or value appears in the Fennec upload form and in the Flank node’s dropdown.
Uploading an image: pick a slot on Fennec’s Body Parts Library page, set the
filters the slot declares, and leave any of them on Universal to make the image
match every value of that filter. Switching slots clears filter values the new
slot does not declare, so a stale value cannot ride along into the upload.
Compatibility hazards
Section titled “Compatibility hazards”- Renaming or removing a legacy key breaks nudify.
bust,genitalia,sizeandstyleare hardcoded inNudifyService. The taxonomy schema does not protect them, and an admin edit that drops or renames them is accepted; the next legacy request then finds no reference. Known and accepted. - Universal uploads are invisible to nudify. An upload with every filter left
on
UniversalstoresNULLattributes, which the legacy strict query cannot match. Existing tagged rows keep it working; a replacement image uploaded without explicitsizeorstylesilently will not. - Removing a filter value does not invalidate rows using it. Such a row becomes unreachable through that filter but stays in the library and still enters the pool for queries that do not filter on that key.