Apply review fixes: V3 registration, compatibility rules, vocabulary cleanup

- Export V3 comfy_entrypoint only when comfy_api V3 symbols exist, else V1
  mappings; narrow the V3 import guard so real import errors surface
- Reject age_min > age_max with an input-named ValueError
- Validate data file structure at load with path and field in errors
- Sync JS sex filter lists with bundled data; test fails on drift
- Filter incompatible hair length/style and build/chest/shoulders pairs;
  user-fixed or allow-listed values win when nothing compatible remains
- Join bust as "<size> <shape> bust"
- Vocabulary: fix malformed phrases (softly built build, hair worn buzz
  cut), remove duplicate and conflicting entries, rename Maori to Maori
  with macron, add skin tones, body types, hair styles, clothing,
  footwear, accessories, female facial hair and male makeup options
- Bump version to 1.4.0
This commit is contained in:
BRADSEC
2026-10-31 13:13:15 +10:00
parent 1ce4dc851f
commit 205681e121
37 changed files with 502 additions and 278 deletions
+8 -4
View File
@@ -83,13 +83,17 @@ The first pin is the full comma-separated description. Individual pins (`sex`, `
## Data files
All values live in `data/` as plain JSON, split into `shared/`, `male/`, and `female/`. Edit them directly to add, remove, or change any value, no code changes needed. New entries take effect on the next ComfyUI restart.
All values live in `data/` as plain JSON, split into `shared/`, `male/`, and `female/`. Edit them directly to add, remove, or change any value. New entries take effect on the next ComfyUI restart. Each file must contain a non-empty list with the required fields; invalid structure reports the file path and field at load time. The JavaScript sex-filter lists also need updating when sex-specific labels change.
Each entry follows this structure:
```json
{ "label": "auburn", "description": "auburn" }
```
The `label` appears in dropdowns; the `description` goes into the prompt. They can differ: eye colours use grounded descriptions (`"label": "green", "description": "muted natural green eyes"`), and `clean shaven` / `neutral` / `none` use an empty description so they add nothing.
The `label` appears in dropdowns; the `description` goes into the prompt. They can differ: eye colours use grounded descriptions (`"label": "green", "description": "muted green eyes"`), and `clean shaven` / `neutral` / `none` use an empty description so they add nothing. Nationality entries use `label` and `descriptor`; shared face features are strings.
Random hair styles respect the selected hair length, and random chest and shoulder choices avoid clear conflicts with slim, lean, slight, or petite builds. Allow lists draw from compatible choices when possible; if none fit, the node keeps your listed choices. Bundled labels use their bundled descriptions, and custom values remain literal. If you fix both conflicting values, the node keeps both. Bust size and shape join as `<size> <shape> bust`. When both non-zero age bounds are given, `age_min` must not exceed `age_max`.
Removed labels may remain in saved workflow widgets. A removed `fixed` label can match a remaining entry through fuzzy matching; otherwise the node warns and picks randomly. A removed `allow_list` label can likewise match a remaining entry; an unmatched value becomes a literal descriptor. Re-select affected values after loading older workflows.
## Testing
@@ -102,11 +106,11 @@ python -m unittest test_random_person -v
## Requirements
- ComfyUI (any recent version, Nodes 2.0 compatible)
- ComfyUI with either the V3 `comfy_api.v0_0_2` symbols or the legacy V1 node API
- Python 3.10+
- No additional pip packages required
The node registers on both the legacy V1 dict API (`NODE_CLASS_MAPPINGS`) and the V3 schema API (`comfy_entrypoint`), so it loads on older and newer ComfyUI builds alike.
The package exports `comfy_entrypoint` when the required V3 symbols exist. Otherwise it exports `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` for V1. It never exports both registration paths. The V3 adapter was source-reviewed against ComfyUI backend 0.42.0, but no ComfyUI runtime version was tested. Unit tests ran with Python 3.13.5.
## Support
+9 -10
View File
@@ -1,18 +1,17 @@
"""ComfyUI_RandomPerson: randomised structured person descriptions.
Exports V1 NODE_CLASS_MAPPINGS (authoritative on the 0.25.0 if/elif loader) and
comfy_entrypoint for builds whose loader prefers the V3 schema API. WEB_DIRECTORY
Exports the V3 entrypoint when available, otherwise V1 mappings. WEB_DIRECTORY
ships the sex-aware widget extension.
"""
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from . import nodes as _nodes
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
try:
from .nodes import comfy_entrypoint # noqa: F401
__all__.append("comfy_entrypoint")
except ImportError:
pass
if hasattr(_nodes, "comfy_entrypoint"):
comfy_entrypoint = _nodes.comfy_entrypoint
__all__ = ["comfy_entrypoint", "WEB_DIRECTORY"]
else:
NODE_CLASS_MAPPINGS = _nodes.NODE_CLASS_MAPPINGS
NODE_DISPLAY_NAME_MAPPINGS = _nodes.NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+10 -10
View File
@@ -15,18 +15,10 @@
"label": "petite",
"description": "petite build"
},
{
"label": "slight",
"description": "slight build"
},
{
"label": "average",
"description": "average build"
},
{
"label": "medium build",
"description": "medium build"
},
{
"label": "athletic",
"description": "athletic build"
@@ -65,7 +57,7 @@
},
{
"label": "softly built",
"description": "softly built build"
"description": "soft build"
},
{
"label": "heavyset",
@@ -74,5 +66,13 @@
{
"label": "stocky",
"description": "stocky build"
},
{
"label": "broad build",
"description": "broad build"
},
{
"label": "soft midsection",
"description": "soft-bellied build"
}
]
]
+1 -2
View File
@@ -2,6 +2,5 @@
{ "label": "round", "description": "round" },
{ "label": "teardrop", "description": "teardrop" },
{ "label": "wide-set", "description": "wide-set" },
{ "label": "close-set", "description": "close-set" },
{ "label": "natural", "description": "natural" }
{ "label": "close-set", "description": "close-set" }
]
+2 -2
View File
@@ -1,8 +1,8 @@
[
{ "label": "petite", "description": "petite" },
{ "label": "petite", "description": "small" },
{ "label": "small", "description": "small" },
{ "label": "average", "description": "average" },
{ "label": "full", "description": "full" },
{ "label": "large", "description": "large" },
{ "label": "extra large", "description": "extra large" }
{ "label": "extra large", "description": "very large" }
]
+4 -1
View File
@@ -32,5 +32,8 @@
{ "label": "business casual", "description": "business-casual attire" },
{ "label": "formal wear", "description": "formal attire" },
{ "label": "loungewear", "description": "loungewear" },
{ "label": "beachwear", "description": "beachwear" }
{ "label": "beachwear", "description": "beachwear" },
{"label": "linen shirt", "description": "a loose linen shirt"},
{"label": "overalls", "description": "denim overalls"},
{"label": "raincoat", "description": "a hooded raincoat"}
]
+1 -21
View File
@@ -7,14 +7,6 @@
"label": "scar through eyebrow",
"description": "a faint scar through one eyebrow"
},
{
"label": "deep set eyes",
"description": "deep-set eyes"
},
{
"label": "wide eyes",
"description": "wide, expressive eyes"
},
{
"label": "cleft chin",
"description": "a softly cleft chin"
@@ -27,10 +19,6 @@
"label": "gap toothed smile",
"description": "a gap-toothed smile"
},
{
"label": "freckles",
"description": "light freckles across the cheeks"
},
{
"label": "beauty mark",
"description": "a beauty mark above the lip"
@@ -38,13 +26,5 @@
{
"label": "birthmark",
"description": "a small birthmark"
},
{
"label": "laugh lines",
"description": "laugh lines"
},
{
"label": "arched brows",
"description": "finely arched brows"
}
]
]
+9 -1
View File
@@ -2,5 +2,13 @@
{
"label": "clean shaven",
"description": ""
},
{
"label": "light facial hair",
"description": "fine facial hair above the upper lip"
},
{
"label": "chin hair",
"description": "a few visible chin hairs"
}
]
]
+1 -2
View File
@@ -24,7 +24,6 @@
{ "label": "hiking boots", "description": "hiking boots" },
{ "label": "work boots", "description": "work boots" },
{ "label": "ankle boots", "description": "ankle boots" },
{ "label": "formal shoes", "description": "formal shoes" },
{ "label": "tactical boots", "description": "tactical boots" },
{ "label": "hiking shoes", "description": "hiking shoes" },
{ "label": "high heels", "description": "high heels" },
@@ -36,5 +35,5 @@
{ "label": "ballet flats", "description": "ballet flats" },
{ "label": "mary janes", "description": "mary janes" },
{ "label": "knee-high boots", "description": "knee-high boots" },
{ "label": "high boots", "description": "high boots" }
{"label": "rubber rain boots", "description": "rubber rain boots"}
]
+1 -13
View File
@@ -59,10 +59,6 @@
"label": "white",
"description": "white"
},
{
"label": "black",
"description": "black"
},
{
"label": "brown",
"description": "brown"
@@ -91,20 +87,12 @@
"label": "sandy",
"description": "sandy blonde"
},
{
"label": "red",
"description": "red"
},
{
"label": "dark red",
"description": "dark red"
},
{
"label": "grey",
"description": "grey"
},
{
"label": "dark grey",
"description": "dark grey"
}
]
]
+14 -18
View File
@@ -1,7 +1,7 @@
[
{
"label": "cropped short",
"description": "cropped short"
"description": "in a short crop"
},
{
"label": "pixie",
@@ -13,7 +13,7 @@
},
{
"label": "chin length bob",
"description": "chin-length bob"
"description": "in a chin-length bob"
},
{
"label": "jaw length bob",
@@ -57,7 +57,7 @@
},
{
"label": "high ponytail",
"description": "high ponytail"
"description": "in a high ponytail"
},
{
"label": "low ponytail",
@@ -81,7 +81,7 @@
},
{
"label": "curtain bangs",
"description": "curtain bangs"
"description": "with curtain bangs"
},
{
"label": "space buns",
@@ -89,20 +89,12 @@
},
{
"label": "twist out",
"description": "twist out"
"description": "in a twist-out"
},
{
"label": "straight",
"description": "straight"
},
{
"label": "curly",
"description": "curly"
},
{
"label": "wavy",
"description": "wavy"
},
{
"label": "mohawk",
"description": "mohawk"
@@ -111,10 +103,6 @@
"label": "faux hawk",
"description": "faux hawk"
},
{
"label": "dreadlocks",
"description": "dreadlocks"
},
{
"label": "afro",
"description": "afro"
@@ -154,5 +142,13 @@
{
"label": "mullet",
"description": "mullet"
},
{
"label": "two strand twists",
"description": "in two-strand twists"
},
{
"label": "bantu knots",
"description": "in Bantu knots"
}
]
]
+1 -9
View File
@@ -7,10 +7,6 @@
"label": "natural makeup",
"description": "natural makeup"
},
{
"label": "soft makeup",
"description": "softly applied makeup"
},
{
"label": "minimal makeup",
"description": "minimal makeup"
@@ -19,10 +15,6 @@
"label": "bold makeup",
"description": "bold makeup"
},
{
"label": "glam makeup",
"description": "glamorous makeup"
},
{
"label": "smokey eye",
"description": "a smokey eye"
@@ -39,4 +31,4 @@
"label": "bold lipstick",
"description": "bold lipstick"
}
]
]
+1 -2
View File
@@ -9,6 +9,5 @@
{ "label": "defined cupids bow", "description": "defined cupid's bow" },
{ "label": "heart shaped lips", "description": "heart-shaped lips" },
{ "label": "rosebud lips", "description": "rosebud lips" },
{ "label": "prominent lower lip", "description": "prominent lower lip" },
{ "label": "wide set lips", "description": "wide-set lips" }
{ "label": "prominent lower lip", "description": "prominent lower lip" }
]
-3
View File
@@ -4,12 +4,9 @@
{ "label": "button nose", "description": "button nose" },
{ "label": "broad nose", "description": "broad nose" },
{ "label": "aquiline nose", "description": "aquiline nose" },
{ "label": "snub nose", "description": "snub nose" },
{ "label": "narrow nose", "description": "narrow nose" },
{ "label": "wide nose", "description": "wide nose" },
{ "label": "crooked nose", "description": "crooked nose" },
{ "label": "flat nose", "description": "flat nose" },
{ "label": "roman nose", "description": "Roman nose" },
{ "label": "rounded nose tip", "description": "rounded nose tip" },
{ "label": "high nose bridge", "description": "high nose bridge" },
{ "label": "low nose bridge", "description": "low nose bridge" }
+1 -1
View File
@@ -4,5 +4,5 @@
{ "label": "broad", "description": "broad shoulders" },
{ "label": "sloping", "description": "sloping shoulders" },
{ "label": "square", "description": "square shoulders" },
{ "label": "soft", "description": "soft shoulders" }
{ "label": "soft", "description": "rounded shoulders" }
]
+11 -11
View File
@@ -35,10 +35,6 @@
"label": "stocky",
"description": "stocky build"
},
{
"label": "broad shouldered",
"description": "broad-shouldered build"
},
{
"label": "tall lean",
"description": "tall and lean build"
@@ -55,20 +51,24 @@
"label": "heavyset",
"description": "heavyset build"
},
{
"label": "barrel chested",
"description": "barrel-chested build"
},
{
"label": "compact",
"description": "compact build"
},
{
"label": "softly built",
"description": "softly built build"
"description": "soft build"
},
{
"label": "dad bod",
"description": "dad bod build"
"description": "soft-bellied build"
},
{
"label": "broad build",
"description": "broad build"
},
{
"label": "soft midsection",
"description": "soft-bellied build"
}
]
]
+4 -1
View File
@@ -30,5 +30,8 @@
{ "label": "business casual", "description": "business-casual attire" },
{ "label": "formal wear", "description": "formal attire" },
{ "label": "loungewear", "description": "loungewear" },
{ "label": "beachwear", "description": "beachwear" }
{ "label": "beachwear", "description": "beachwear" },
{"label": "linen shirt", "description": "a loose linen shirt"},
{"label": "overalls", "description": "denim overalls"},
{"label": "raincoat", "description": "a hooded raincoat"}
]
+1 -17
View File
@@ -7,10 +7,6 @@
"label": "scar through eyebrow",
"description": "a scar through one eyebrow"
},
{
"label": "deep set eyes",
"description": "deep-set eyes"
},
{
"label": "high cheekbones",
"description": "high cheekbones"
@@ -23,10 +19,6 @@
"label": "strong jaw",
"description": "a strong, defined jaw"
},
{
"label": "laugh lines",
"description": "laugh lines"
},
{
"label": "gap toothed smile",
"description": "a gap-toothed smile"
@@ -35,10 +27,6 @@
"label": "dimples",
"description": "dimples"
},
{
"label": "freckles",
"description": "light freckles"
},
{
"label": "mole on cheek",
"description": "a mole on one cheek"
@@ -46,9 +34,5 @@
{
"label": "birthmark on jaw",
"description": "a birthmark on the jaw"
},
{
"label": "weathered skin",
"description": "weathered, sun-aged skin"
}
]
]
+2 -2
View File
@@ -24,7 +24,7 @@
{ "label": "hiking boots", "description": "hiking boots" },
{ "label": "work boots", "description": "work boots" },
{ "label": "ankle boots", "description": "ankle boots" },
{ "label": "formal shoes", "description": "formal shoes" },
{ "label": "tactical boots", "description": "tactical boots" },
{ "label": "hiking shoes", "description": "hiking shoes" }
{ "label": "hiking shoes", "description": "hiking shoes" },
{"label": "rubber rain boots", "description": "rubber rain boots"}
]
+2 -10
View File
@@ -25,7 +25,7 @@
},
{
"label": "warm honey",
"description": "warm honey"
"description": "honey blonde"
},
{
"label": "golden blonde",
@@ -51,10 +51,6 @@
"label": "white",
"description": "white"
},
{
"label": "black",
"description": "black"
},
{
"label": "brown",
"description": "brown"
@@ -91,12 +87,8 @@
"label": "dark red",
"description": "dark red"
},
{
"label": "grey",
"description": "grey"
},
{
"label": "dark grey",
"description": "dark grey"
}
]
]
+14 -14
View File
@@ -1,16 +1,12 @@
[
{
"label": "buzz cut",
"description": "buzz cut"
"description": "in a buzz cut"
},
{
"label": "crew cut",
"description": "crew cut"
},
{
"label": "cropped short",
"description": "cropped short"
},
{
"label": "textured crop",
"description": "textured crop"
@@ -37,7 +33,7 @@
},
{
"label": "messy medium",
"description": "messy medium"
"description": "in a tousled medium cut"
},
{
"label": "curly medium",
@@ -53,7 +49,7 @@
},
{
"label": "locs long",
"description": "long locs"
"description": "in long locs"
},
{
"label": "cornrows",
@@ -61,7 +57,7 @@
},
{
"label": "bun",
"description": "bun"
"description": "in a bun"
},
{
"label": "low ponytail",
@@ -73,7 +69,7 @@
},
{
"label": "taper fade",
"description": "taper fade"
"description": "in a taper fade"
},
{
"label": "skin fade",
@@ -99,10 +95,6 @@
"label": "faux hawk",
"description": "faux hawk"
},
{
"label": "dreadlocks",
"description": "dreadlocks"
},
{
"label": "afro",
"description": "afro"
@@ -122,5 +114,13 @@
{
"label": "mullet",
"description": "mullet"
},
{
"label": "two strand twists",
"description": "in two-strand twists"
},
{
"label": "bantu knots",
"description": "in Bantu knots"
}
]
]
+13 -1
View File
@@ -2,5 +2,17 @@
{
"label": "none",
"description": ""
},
{
"label": "subtle concealer",
"description": "subtle concealer"
},
{
"label": "eyeliner",
"description": "black eyeliner"
},
{
"label": "red lipstick",
"description": "red lipstick"
}
]
]
+1 -2
View File
@@ -6,6 +6,5 @@
{ "label": "downturned mouth", "description": "downturned mouth" },
{ "label": "upturned mouth", "description": "naturally upturned mouth" },
{ "label": "defined cupids bow", "description": "defined cupid's bow" },
{ "label": "prominent lower lip", "description": "prominent lower lip" },
{ "label": "wide set lips", "description": "wide-set lips" }
{ "label": "prominent lower lip", "description": "prominent lower lip" }
]
-2
View File
@@ -5,8 +5,6 @@
{ "label": "rounded nose tip", "description": "rounded nose tip" },
{ "label": "curved nose bridge", "description": "curved nose bridge" },
{ "label": "snub nose", "description": "snub nose" },
{ "label": "roman nose", "description": "Roman nose" },
{ "label": "wide nose", "description": "wide nose" },
{ "label": "flat nose", "description": "flat nose" },
{ "label": "narrow nose", "description": "narrow nose" },
{ "label": "upturned nose", "description": "slightly upturned nose" },
+9 -1
View File
@@ -53,7 +53,7 @@
},
{
"label": "small ear piercings",
"description": "small ear piercings"
"description": "small stud earrings in both ears"
},
{
"label": "nose ring",
@@ -98,5 +98,13 @@
{
"label": "bindi",
"description": "a bindi"
},
{
"label": "hearing aids",
"description": "small hearing aids"
},
{
"label": "head wrap",
"description": "a cloth head wrap"
}
]
+6 -3
View File
@@ -1,5 +1,5 @@
[
{ "label": "porcelain", "description": "porcelain" },
{ "label": "porcelain", "description": "very light neutral" },
{ "label": "fair", "description": "fair" },
{ "label": "light", "description": "light" },
{ "label": "light medium", "description": "light medium" },
@@ -12,6 +12,9 @@
{ "label": "golden brown", "description": "golden brown" },
{ "label": "medium brown", "description": "medium brown" },
{ "label": "deep brown", "description": "deep brown" },
{ "label": "deep", "description": "deep" },
{ "label": "ebony", "description": "ebony" }
{ "label": "deep", "description": "very deep brown" },
{ "label": "ebony", "description": "very deep neutral brown" },
{"label": "light brown", "description": "light brown"},
{"label": "neutral medium brown", "description": "neutral medium brown"},
{"label": "rich dark brown", "description": "rich dark brown"}
]
+1 -9
View File
@@ -11,14 +11,6 @@
"label": "warm smile",
"description": "a warm smile"
},
{
"label": "slight smile",
"description": "a slight smile"
},
{
"label": "gentle smile",
"description": "a gentle smile"
},
{
"label": "serious",
"description": "a serious expression"
@@ -37,7 +29,7 @@
},
{
"label": "confident",
"description": "a confident expression"
"description": "a direct, relaxed gaze"
},
{
"label": "relaxed",
+5 -5
View File
@@ -35,16 +35,16 @@
"label": "downturned",
"description": "downturned"
},
{
"label": "heavy-lidded",
"description": "heavy-lidded"
},
{
"label": "narrow",
"description": "narrow"
},
{
"label": "large",
"description": "large, expressive"
"description": "large"
},
{
"label": "prominent",
"description": "prominent"
}
]
-4
View File
@@ -23,10 +23,6 @@
"label": "bushy",
"description": "bushy eyebrows"
},
{
"label": "full natural",
"description": "full natural brows"
},
{
"label": "sparse",
"description": "sparse brows"
+4 -4
View File
@@ -17,7 +17,7 @@
},
{
"label": "hazel",
"description": "hazel eyes with brown and green flecks"
"description": "hazel eyes"
},
{
"label": "hazel green",
@@ -33,7 +33,7 @@
},
{
"label": "blue",
"description": "natural blue eyes"
"description": "blue eyes"
},
{
"label": "light blue",
@@ -57,7 +57,7 @@
},
{
"label": "green",
"description": "muted natural green eyes"
"description": "muted green eyes"
},
{
"label": "light green",
@@ -77,7 +77,7 @@
},
{
"label": "grey",
"description": "natural grey eyes"
"description": "grey eyes"
},
{
"label": "pale grey",
+4 -13
View File
@@ -1,22 +1,13 @@
[
"a faint dusting of freckles across the nose",
"a thin scar through one eyebrow",
"deep dimples when smiling",
"a prominent, straight nose",
"dimples in both cheeks",
"unusually high cheekbones",
"a strong, defined jaw",
"sun-darkened laugh lines at the corners of the eyes",
"a wide, gap-toothed smile",
"a button nose and full cheeks",
"a sharp aquiline nose",
"heavy, expressive eyebrows",
"a small gap between the front teeth",
"a softly cleft chin",
"very full lips",
"thin, precise lips",
"deep-set eyes beneath a strong brow",
"a broad, flat nose",
"small, neat ears",
"a long neck that gave them an elegant bearing",
"a slightly crooked nose, as if once broken",
"a long neck",
"a birthmark at the jaw"
]
]
+1 -1
View File
@@ -62,6 +62,6 @@
{ "label": "Cuban", "descriptor": "Cuban", "from": "from Cuba" },
{ "label": "Venezuelan", "descriptor": "Venezuelan", "from": "from Venezuela" },
{ "label": "Chilean", "descriptor": "Chilean", "from": "from Chile" },
{ "label": "Maori", "descriptor": "Maori", "from": "from New Zealand" },
{"label": "Māori", "descriptor": "Māori"},
{ "label": "Samoan", "descriptor": "Samoan", "from": "from Samoa" }
]
+9 -13
View File
@@ -7,18 +7,6 @@
"label": "clear",
"description": "clear skin"
},
{
"label": "dewy",
"description": "dewy skin"
},
{
"label": "glowing",
"description": "glowing skin"
},
{
"label": "matte",
"description": "matte skin"
},
{
"label": "oily",
"description": "an oily sheen"
@@ -53,7 +41,7 @@
},
{
"label": "ruddy",
"description": "a ruddy complexion"
"description": "redness across the cheeks"
},
{
"label": "fine lines",
@@ -74,5 +62,13 @@
{
"label": "leathery",
"description": "leathery, sun-aged skin"
},
{
"label": "visible pores",
"description": "natural visible pores"
},
{
"label": "under-eye lines",
"description": "fine lines under the eyes"
}
]
+33 -34
View File
@@ -1,44 +1,43 @@
import { app } from "../../scripts/app.js";
// ── Per-category labels that are sex-specific ─────────────────────────────────
// Mirrors the diff between data/male/ and data/female/ JSON files.
// Per-category differences are checked against bundled JSON by the unit tests.
const MALE_ONLY = {
hair_style_fixed: ["braids short","buzz cut","crew cut","curly medium","locs long","locs short","messy medium","pompadour","quiff","side part","skin fade","slicked back","taper fade","undercut"],
hair_length_fixed: [],
body_type_fixed: ["barrel chested","broad shouldered","compact","dad bod"],
face_shape_fixed: ["rectangular"],
nose_shape_fixed: ["aquiline nose","curved nose bridge","roman nose","rounded nose tip"],
mouth_shape_fixed: [],
hair_color_fixed: [],
face_feature_fixed: ["birthmark on jaw","heavy brow","mole on cheek","strong jaw","weathered skin"],
facial_hair_fixed: ["circle beard","full beard","goatee","heavy stubble","light stubble","moustache","mutton chops","short beard","soul patch","van dyke"],
makeup_fixed: [],
clothing_fixed: ["bomber jacket","crew-neck sweater","flannel shirt","henley shirt","parka","polo shirt","three-piece suit","tracksuit","wool overcoat","work shirt"],
footwear_fixed: [],
shoulders_fixed: ["muscular"],
chest_fixed: ["average","barrel","broad","flat","muscular"],
bust_size_fixed: [],
bust_shape_fixed: [],
face_shape_fixed: ["rectangular"],
nose_shape_fixed: ["curved nose bridge", "snub nose"],
mouth_shape_fixed: [],
face_feature_fixed: ["birthmark on jaw", "heavy brow", "mole on cheek", "strong jaw"],
hair_color_fixed: ["red"],
hair_style_fixed: ["bowl cut", "braids short", "buzz cut", "caesar cut", "crew cut", "curly", "curly medium", "french crop", "locs long", "locs short", "messy medium", "pompadour", "quiff", "side part", "skin fade", "slicked back", "taper fade", "undercut", "wavy"],
hair_length_fixed: [],
facial_hair_fixed: ["circle beard", "full beard", "goatee", "heavy stubble", "light stubble", "moustache", "mutton chops", "short beard", "soul patch", "van dyke"],
body_type_fixed: ["compact", "dad bod", "medium build", "slight"],
shoulders_fixed: ["muscular"],
chest_fixed: ["average", "barrel", "broad", "flat", "muscular"],
bust_size_fixed: ["none"],
bust_shape_fixed: ["none"],
makeup_fixed: ["eyeliner", "subtle concealer"],
clothing_fixed: ["bomber jacket", "crew-neck sweater", "flannel shirt", "henley shirt", "parka", "polo shirt", "three-piece suit", "tracksuit", "wool overcoat", "work shirt"],
footwear_fixed: [],
};
const FEMALE_ONLY = {
hair_style_fixed: ["braid over shoulder","braids","chin length bob","curtain bangs","half up","high ponytail","jaw length bob","layered","locs","loose curls","loose waves","messy","pixie","pulled back loose","space buns","tight curls","twist out"],
hair_length_fixed: ["chin length","very long"],
body_type_fixed: ["curvy","full figured","hourglass","pear","petite"],
face_shape_fixed: ["heart"],
nose_shape_fixed: ["button nose","upturned nose"],
mouth_shape_fixed: ["bow shaped lips","heart shaped lips","rosebud lips"],
hair_color_fixed: ["platinum blonde","strawberry blonde"],
face_feature_fixed: ["arched brows","beauty mark","birthmark","wide eyes"],
facial_hair_fixed: [],
makeup_fixed: ["bold lipstick","bold makeup","glam makeup","minimal makeup","natural makeup","red lipstick","smokey eye","soft makeup","winged eyeliner"],
clothing_fixed: ["blouse","camisole","cardigan","cocktail dress","jumpsuit","knit sweater","maxi dress","off-shoulder top","peplum top","sundress","wool coat","wrap dress"],
footwear_fixed: ["ballet flats","high boots","high heels","kitten heels","knee-high boots","mary janes","platform heels","stilettos","strappy heels","wedges"],
shoulders_fixed: ["soft"],
chest_fixed: [],
bust_size_fixed: ["average","extra large","full","large","petite","small"],
bust_shape_fixed: ["close-set","natural","round","teardrop","wide-set"],
face_shape_fixed: ["heart"],
nose_shape_fixed: ["button nose"],
mouth_shape_fixed: ["bow shaped lips", "heart shaped lips", "rosebud lips"],
face_feature_fixed: ["beauty mark", "birthmark"],
hair_color_fixed: ["platinum blonde", "strawberry blonde"],
hair_style_fixed: ["blunt cut", "braid over shoulder", "braids", "chignon", "chin length bob", "cropped short", "curtain bangs", "fishtail braid", "french braid", "half up", "high ponytail", "jaw length bob", "layered", "locs", "loose curls", "loose waves", "messy", "pixie", "pulled back loose", "shag", "space buns", "tight curls", "twist out", "updo", "wispy", "wolf cut"],
hair_length_fixed: ["chin length", "very long"],
facial_hair_fixed: ["chin hair", "light facial hair"],
body_type_fixed: ["curvy", "full figured", "hourglass", "pear", "petite"],
shoulders_fixed: ["soft"],
chest_fixed: ["none"],
bust_size_fixed: ["average", "extra large", "full", "large", "petite", "small"],
bust_shape_fixed: ["close-set", "round", "teardrop", "wide-set"],
makeup_fixed: ["bold lipstick", "bold makeup", "minimal makeup", "natural makeup", "smokey eye", "winged eyeliner"],
clothing_fixed: ["blouse", "camisole", "cardigan", "cocktail dress", "jumpsuit", "knit sweater", "maxi dress", "off-shoulder top", "peplum top", "sundress", "wool coat", "wrap dress"],
footwear_fixed: ["ballet flats", "high heels", "kitten heels", "knee-high boots", "mary janes", "platform heels", "stilettos", "strappy heels", "wedges"],
};
const SEX_GATED_WIDGETS = Object.keys(MALE_ONLY);
+121 -21
View File
@@ -18,14 +18,13 @@ Per-category MODE:
Age uses numeric age_min / age_max (0 = no bound) or age_fixed.
Dual-API: the shared core (generate_person) is wrapped by a V1 class
(NODE_CLASS_MAPPINGS, authoritative on the 0.25.0 if/elif loader) and an
import-guarded V3 class (comfy_entrypoint) for builds whose loader prefers
the comfy_api schema. node_id "RandomPersonNode" is fixed in both so the
js/ sex-aware widget extension keeps matching.
The shared core (generate_person) has V1 and V3 adapters. The package exports
only the adapter supported by the available comfy_api symbols. node_id
"RandomPersonNode" is fixed in both so the js/ extension keeps matching.
"""
import json
import importlib
import os
import random
from functools import lru_cache
@@ -42,8 +41,28 @@ AGE_MODES = ["random", "fixed", "off"]
def load(subdir, filename):
# Parsed once per process. Callers must treat the result as read-only.
# Matches the documented "edit data, restart ComfyUI" workflow.
with open(os.path.join(DATA_DIR, subdir, filename), encoding="utf-8") as f:
return json.load(f)
path = os.path.join(DATA_DIR, subdir, filename)
with open(path, encoding="utf-8") as f:
try:
data = json.load(f)
except json.JSONDecodeError as exc:
raise ValueError(f"{path}: invalid JSON at line {exc.lineno}, column {exc.colno}") from exc
if not isinstance(data, list) or not data:
raise ValueError(f"{path}: expected a non-empty list")
for index, item in enumerate(data):
if filename == "age.json":
if not isinstance(item, dict) or type(item.get("age")) is not int:
raise ValueError(f"{path}: [{index}].age must be an integer")
elif filename == "face_feature.json" and subdir == "shared":
if not isinstance(item, str) or not item:
raise ValueError(f"{path}: [{index}] must be a non-empty string")
else:
for field in (("label", "descriptor") if filename == "nationality.json" else ("label", "description")):
if not isinstance(item, dict) or not isinstance(item.get(field), str) or not item[field]:
if field == "description" and isinstance(item, dict) and item.get(field) == "":
continue
raise ValueError(f"{path}: [{index}].{field} must be a string")
return data
def load_shared(fn): return load("shared", fn)
def load_sex(sex, fn): return load(sex, fn)
@@ -152,6 +171,46 @@ DEFAULT_OFF = {"skin_texture", "eyebrows", "face_feature", "facial_hair",
"expression", "accessories", "makeup",
"shoulders", "chest", "bust_size", "bust_shape", "clothing", "footwear"}
# Styles with an explicit length requirement. Other styles fit any non-shaved length.
_STYLE_LENGTHS = {
"buzz cut": {"very short", "short"}, "crew cut": {"very short", "short"},
"skin fade": {"very short", "short"}, "taper fade": {"very short", "short"},
"faux hawk": {"very short", "short", "medium"},
"pompadour": {"short", "medium"}, "quiff": {"short", "medium"},
"caesar cut": {"very short", "short"}, "french crop": {"very short", "short"},
"textured crop": {"very short", "short"}, "pixie": {"very short", "short"},
"cropped short": {"very short", "short"}, "bowl cut": {"short", "ear length"},
"chin length bob": {"chin length"}, "jaw length bob": {"chin length"},
"braids short": {"short", "ear length", "chin length"},
"locs short": {"short", "ear length", "chin length"},
"curly medium": {"medium", "shoulder"}, "messy medium": {"medium", "shoulder"},
"locs long": {"long", "very long"}, "braid over shoulder": {"shoulder", "long", "very long"},
"high ponytail": {"medium", "shoulder", "long", "very long"},
"low ponytail": {"medium", "shoulder", "long", "very long"},
"bun": {"medium", "shoulder", "long", "very long"},
"space buns": {"medium", "shoulder", "long", "very long"},
"half up": {"medium", "shoulder", "long", "very long"},
"updo": {"medium", "shoulder", "long", "very long"},
"chignon": {"medium", "shoulder", "long", "very long"},
"french braid": {"medium", "shoulder", "long", "very long"},
"fishtail braid": {"medium", "shoulder", "long", "very long"},
}
_SLENDER_BUILDS = {"slim", "slight", "petite", "lean", "tall lean"}
_BROAD_CHESTS = {"barrel", "broad", "muscular"}
_BROAD_SHOULDERS = {"broad", "muscular"}
def _conflicts(key, item, other_key, other):
labels = {key: item["label"], other_key: other["label"]}
if {key, other_key} == {"hair_length", "hair_style"}:
length, style = labels["hair_length"], labels["hair_style"]
return length == "shaved" or (style in _STYLE_LENGTHS and length not in _STYLE_LENGTHS[style])
if {key, other_key} == {"body_type", "chest"}:
return labels["body_type"] in _SLENDER_BUILDS and labels["chest"] in _BROAD_CHESTS
if {key, other_key} == {"body_type", "shoulders"}:
return labels["body_type"] in _SLENDER_BUILDS and labels["shoulders"] in _BROAD_SHOULDERS
return False
def _mode_default(key):
return "off" if key in DEFAULT_OFF else "random"
@@ -164,8 +223,8 @@ _MODE_TIP = "How this attribute is chosen: random (any), allow_list (subset yo
_ALLOW_TIP = "Comma-separated values used when mode=allow_list. Unknown values are added as literal descriptors."
_FIXED_TIP = "Exact value used when mode=fixed. (none) falls back to random."
_AGEMODE_TIP = "How to pick the age: random within range, fixed value, or off (no age in the description)."
_AGEMIN_TIP = "Minimum age. 0 = no lower bound (defaults to 21). Cannot go below 21."
_AGEMAX_TIP = "Maximum age. 0 = no upper bound (defaults to 90). Minimum is always 21."
_AGEMIN_TIP = "Minimum age. 0 = no lower bound (defaults to 21). Cannot go below 21 or exceed age_max."
_AGEMAX_TIP = "Maximum age. 0 = no upper bound (defaults to 90). Must not be below age_min."
_AGEFIX_TIP = "Exact age when mode=fixed. 0 = ignored. Values below 21 are clamped to 21."
_EXTRA_TIP = "Free text appended to the description as-is, comma-separated. Pose, setting, props, extra detail, etc."
@@ -211,7 +270,7 @@ def _best_match(full_list, token):
return candidates[0]
def resolve(mode, full_list, allow_str="", fixed_val="", rng=random):
def resolve(mode, full_list, allow_str="", fixed_val="", rng=random, compatible=None):
"""Pick one item from full_list according to mode. Returns None if off."""
if mode == "off":
return None
@@ -229,7 +288,10 @@ def resolve(mode, full_list, allow_str="", fixed_val="", rng=random):
matched_items, custom_strings = [], []
for t in tokens:
tl = t.lower()
hits = [item for item in full_list if item_matches(item, tl) > 0]
hits = [item for item in full_list
if (item.get("label", "") if isinstance(item, dict) else item).lower() == tl]
if not hits:
hits = [item for item in full_list if item_matches(item, tl) > 0]
if hits:
matched_items.extend(hits)
else:
@@ -244,13 +306,21 @@ def resolve(mode, full_list, allow_str="", fixed_val="", rng=random):
pool = deduped + [{"label": s, "description": s} for s in custom_strings]
if pool:
if compatible is not None:
allowed = {id(item) for item in compatible}
filtered = [item for item in deduped if id(item) in allowed]
filtered.extend(pool[len(deduped):])
pool = filtered or pool
return rng.choice(pool)
print(f"[RandomPersonNode] WARNING: allow_list '{allow_str}' matched nothing - using random.")
return rng.choice(full_list)
options = compatible if compatible is not None else full_list
return rng.choice(options) if options else None
def resolve_age(mode, age_min, age_max, age_fixed_val, all_ages, rng=random):
if age_min > 0 and age_max > 0 and age_min > age_max:
raise ValueError("age_min must not exceed age_max")
if mode == "off":
return None
if mode == "fixed" and age_fixed_val > 0:
@@ -261,8 +331,6 @@ def resolve_age(mode, age_min, age_max, age_fixed_val, all_ages, rng=random):
print(f"[RandomPersonNode] WARNING: age {age_fixed_val} not in data - using random.")
lo = max(age_min, 21) if age_min > 0 else 21
hi = min(age_max, 90) if age_max > 0 else 90
if lo > hi:
lo, hi = hi, lo
pool = [a for a in all_ages if lo <= a["age"] <= hi]
if not pool:
print(f"[RandomPersonNode] WARNING: no ages in {lo}-{hi} - using full list.")
@@ -292,7 +360,7 @@ def _bust_phrase(size_str, shape_str):
return ""
article = "an" if lead[:1].lower() in "aeiou" else "a"
if size_str and shape_str:
return f"{article} {size_str}, {shape_str} bust"
return f"{article} {size_str} {shape_str} bust"
return f"{article} {lead} bust"
@@ -401,9 +469,32 @@ def generate_person(seed, randomize, sex, category_args,
return load(sub, f"{key}.json")
p = {}
paired = {"hair_style", "hair_length", "body_type", "chest", "shoulders"}
for key in paired:
mode, _, fixed = category_args[key]
if mode == "fixed" and fixed.strip() != "(none)":
match = _best_match(load_list(key), fixed.strip().lower())
if match is not None:
p[key] = match
for key, _, _ in CATEGORY_SPECS:
if key in p:
continue
if key == "hair_style" and "hair_length" not in p:
continue
mode, allow, fixed = category_args[key]
p[key] = resolve(mode, load_list(key), allow, fixed, rng=rng)
options = load_list(key)
compatible = None
if key in paired and mode != "fixed":
compatible = [item for item in options if all(
not _conflicts(key, item, other_key, other)
for other_key, other in p.items() if other is not None)]
p[key] = resolve(mode, options, allow, fixed, rng=rng, compatible=compatible)
if "hair_style" not in p:
mode, allow, fixed = category_args["hair_style"]
options = load_list("hair_style")
compatible = [item for item in options if not p.get("hair_length")
or not _conflicts("hair_style", item, "hair_length", p["hair_length"])]
p["hair_style"] = resolve(mode, options, allow, fixed, rng=rng, compatible=compatible)
age = resolve_age(age_mode, age_min, age_max, age_fixed, load_shared("age.json"), rng=rng)
description = build_description(
@@ -592,7 +683,20 @@ RandomPersonNode = NODE_CLASS_MAPPINGS["RandomPersonNode"]
# -- V3 node factory (import-guarded comfy_api schema) -------------------------
try:
from comfy_api.v0_0_2 import io, ComfyExtension
_comfy_api = importlib.import_module("comfy_api.v0_0_2")
except ModuleNotFoundError as exc:
if exc.name not in ("comfy_api", "comfy_api.v0_0_2"):
raise
_comfy_api = None
if (_comfy_api is not None and hasattr(_comfy_api, "ComfyExtension")
and hasattr(_comfy_api, "io")
and all(hasattr(_comfy_api.io, name) for name in
("ComfyNode", "Schema", "NodeOutput", "Int", "Boolean", "Combo", "String"))
and all(hasattr(getattr(_comfy_api.io, name), "Input") for name in
("Int", "Boolean", "Combo", "String"))
and all(hasattr(getattr(_comfy_api.io, name), "Output") for name in ("Int", "String"))):
io, ComfyExtension = _comfy_api.io, _comfy_api.ComfyExtension
def _make_v3_node(node_id, display_name, keys, include_age, output_names):
key_set = set(keys)
@@ -669,7 +773,3 @@ try:
async def comfy_entrypoint() -> "RandomPersonExtension":
return RandomPersonExtension()
except ImportError:
# Older ComfyUI without comfy_api: V1 mappings above are the only path.
pass
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui_randomperson"
description = "Generate randomised, structured physical person descriptions for image-generation prompts. Sex-aware traits, per-category random/allow_list/fixed/off modes, age ranges, body details, and individual output pins. Full node plus Identity, Face, Hair, Body, and Style segment nodes under the Random Person menu category."
version = "1.3.1"
version = "1.4.0"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = []
+197 -10
View File
@@ -4,7 +4,16 @@ Pure logic, no ComfyUI required: reads the bundled data/ JSON only.
Run: python -m unittest test_random_person -v
"""
import asyncio
import importlib.util
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import nodes as core
@@ -74,19 +83,19 @@ class TestSelection(unittest.TestCase):
for key in ("nationality", "complexion", "eyes", "hair_color", "body_type"):
self.assertEqual(req[f"{key}_mode"][1]["default"], "random", key)
def test_female_never_grows_facial_hair(self):
for seed in range(40):
self.assertEqual(run(seed=seed, sex="female")[7], "") # facial_hair pin empty
def test_female_facial_hair_can_be_selected(self):
out = run(sex="female", facial_hair=("fixed", "", "chin hair"))
self.assertEqual(out[7], "a few visible chin hairs")
def test_male_makeup_empty(self):
for seed in range(40):
self.assertEqual(run(seed=seed, sex="male")[11], "") # makeup pin empty
def test_male_makeup_can_be_selected(self):
out = run(sex="male", makeup=("fixed", "", "eyeliner"))
self.assertEqual(out[11], "black eyeliner")
def test_skin_texture_in_complexion_pin(self):
out = run(skin_texture=("fixed", "", "dewy"),
out = run(skin_texture=("fixed", "", "visible pores"),
complexion=("fixed", "", "tan"))
self.assertIn("tan", out[4])
self.assertIn("dewy", out[4])
self.assertIn("natural visible pores", out[4])
def test_eye_shape_merges_into_eyes(self):
# eye_shape sits in front of the eye colour, single "eyes" suffix, and
@@ -120,6 +129,10 @@ class TestSelection(unittest.TestCase):
class TestAge(unittest.TestCase):
def test_reversed_age_bounds_name_inputs(self):
with self.assertRaisesRegex(ValueError, "age_min.*age_max"):
run(age_min=80, age_max=30)
def test_age_off(self):
out = run(age_mode="off")
self.assertEqual(out[2], "")
@@ -234,12 +247,13 @@ class TestBodyData(unittest.TestCase):
out = run(sex="female",
bust_size=("fixed", "", "full"),
bust_shape=("fixed", "", "round"))
self.assertIn("a full, round bust", out[0])
self.assertIn("a full round bust", out[0])
def test_bust_size_only_female(self):
out = run(sex="female", bust_size=("fixed", "", "petite"),
bust_shape=("off", "", "(none)"))
self.assertIn("a petite bust", out[0])
self.assertIn("a small bust", out[0])
def test_shoulders_pin_present(self):
names = core.RETURN_NAMES
@@ -275,6 +289,179 @@ class TestBodyData(unittest.TestCase):
self.assertIn("a full bust", out[0])
class TestReviewFixes(unittest.TestCase):
def _import_package(self, api):
package = "random_person_review_test"
spec = importlib.util.spec_from_file_location(
package, Path(__file__).parent / "__init__.py",
submodule_search_locations=[str(Path(__file__).parent)])
module = importlib.util.module_from_spec(spec)
original = core.importlib.import_module
def import_api(name, *args, **kwargs):
if name == "comfy_api.v0_0_2":
if isinstance(api, Exception):
raise api
return api
return original(name, *args, **kwargs)
sys.modules[package] = module
try:
with patch.object(core.importlib, "import_module", side_effect=import_api):
spec.loader.exec_module(module)
return module
finally:
sys.modules.pop(package, None)
sys.modules.pop(package + ".nodes", None)
def test_v1_only_when_v3_symbols_missing(self):
module = self._import_package(SimpleNamespace(io=SimpleNamespace()))
self.assertTrue(hasattr(module, "NODE_CLASS_MAPPINGS"))
self.assertFalse(hasattr(module, "comfy_entrypoint"))
def test_v1_only_when_comfy_api_missing(self):
error = ModuleNotFoundError("No module named 'comfy_api'", name="comfy_api")
module = self._import_package(error)
self.assertEqual(len(module.NODE_CLASS_MAPPINGS), 6)
self.assertFalse(hasattr(module, "comfy_entrypoint"))
def test_v3_only_when_symbols_present(self):
class Kind:
@staticmethod
def Input(*args, **kwargs):
return args, kwargs
Output = Input
class ComfyNode:
pass
class ComfyExtension:
pass
class NodeOutput(tuple):
def __new__(cls, *args):
return super().__new__(cls, args)
io = SimpleNamespace(ComfyNode=ComfyNode, Schema=lambda **kwargs: kwargs,
NodeOutput=NodeOutput, Int=Kind, Boolean=Kind,
Combo=Kind, String=Kind)
module = self._import_package(SimpleNamespace(io=io, ComfyExtension=ComfyExtension))
self.assertTrue(hasattr(module, "comfy_entrypoint"))
self.assertFalse(hasattr(module, "NODE_CLASS_MAPPINGS"))
extension = asyncio.run(module.comfy_entrypoint())
nodes = asyncio.run(extension.get_node_list())
self.assertEqual({node.define_schema()["node_id"] for node in nodes},
{spec[0] for spec in core.NODE_SPECS})
self.assertIsInstance(nodes[0].execute(1, False, "male"), NodeOutput)
def test_v3_dependency_import_error_propagates(self):
error = ModuleNotFoundError("missing internal dependency", name="internal_dependency")
with self.assertRaisesRegex(ModuleNotFoundError, "internal dependency"):
self._import_package(error)
def test_data_validation_reports_path_and_field(self):
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "shared" / "eyes.json"
path.parent.mkdir()
path.write_text(json.dumps([{"label": "blue"}]))
with patch.object(core, "DATA_DIR", root):
core.load.cache_clear()
with self.assertRaisesRegex(ValueError, r"eyes.json.*description"):
core.load("shared", "eyes.json")
core.load.cache_clear()
def test_maori_saved_value_and_new_entries(self):
nationality = core.load_shared("nationality.json")
self.assertEqual(next(item for item in nationality if item["label"] == "Māori"),
{"label": "Māori", "descriptor": "Māori"})
self.assertNotIn("Maori", {item["label"] for item in nationality})
self.assertEqual(core.resolve("allow_list", nationality, "Maori")["description"], "Maori")
self.assertIn("hearing aids", {item["label"] for item in core.load_shared("accessories.json")})
self.assertIn("chin hair", {item["label"] for item in core.load_sex("female", "facial_hair.json")})
self.assertIn("eyeliner", {item["label"] for item in core.load_sex("male", "makeup.json")})
self.assertEqual(next(item["description"] for item in core.load_shared("complexion.json")
if item["label"] == "porcelain"), "very light neutral")
self.assertEqual(next(item["description"] for item in core.load_sex("female", "bust_size.json")
if item["label"] == "petite"), "small")
def test_js_sex_lists_match_data(self):
script = (Path(__file__).parent / "js/random_person_node.js").read_text()
for sex, other in (("male", "female"), ("female", "male")):
section = re.search(rf"const {sex.upper()}_ONLY = \{{(.*?)\n\}};", script, re.S).group(1)
actual = {key: set(re.findall(r'"([^"]+)"', values))
for key, values in re.findall(r"\s*(\w+)_fixed:\s*\[([^]]*)\]", section)}
expected = {}
for key in core.SEX_GATED:
labels = {item["label"] for item in core.load_sex(sex, f"{key}.json")}
others = {item["label"] for item in core.load_sex(other, f"{key}.json")}
expected[key] = labels - others
self.assertEqual(actual, expected)
def test_random_hair_and_body_avoid_clear_conflicts(self):
for sex in ("male", "female"):
for seed in range(200):
out = run(seed=seed, sex=sex)
hair = out[6]
self.assertNotIn("shaved hair worn", out[0], (sex, seed, out[0]))
self.assertFalse("long" in hair and "buzz cut" in hair, (sex, seed, hair))
if out[8] in {"slim build", "slight build", "petite build", "lean build", "tall and lean build"}:
self.assertNotIn(out[13], {"a barrel chest", "a broad chest", "a muscular chest"})
self.assertNotIn(out[12], {"broad shoulders", "muscular shoulders"})
def test_fixed_one_side_filters_random_other_side(self):
for seed in range(80):
long_hair = run(seed=seed, sex="male", hair_length=("fixed", "", "long"))
self.assertNotIn("buzz cut", long_hair[6])
self.assertNotIn("faux hawk", long_hair[6])
buzz = run(seed=seed, sex="male", hair_style=("fixed", "", "buzz cut"))
self.assertNotIn("long", buzz[6])
slim = run(seed=seed, sex="male", body_type=("fixed", "", "slim"))
self.assertNotIn(slim[13], {"a barrel chest", "a broad chest", "a muscular chest"})
broad = run(seed=seed, sex="male", chest=("fixed", "", "broad"))
self.assertNotIn(broad[8], {"slim build", "slight build", "lean build"})
def test_explicit_incompatible_values_remain(self):
out = run(sex="male", hair_length=("fixed", "", "long"),
hair_style=("fixed", "", "buzz cut"), body_type=("fixed", "", "slim"),
chest=("fixed", "", "barrel"))
self.assertIn("buzz cut", out[6])
self.assertEqual(out[13], "a barrel chest")
def test_incompatible_bundled_hair_allow_list_keeps_description(self):
out = run(sex="male", hair_style=("fixed", "", "buzz cut"),
hair_length=("allow_list", "long", "(none)"))
self.assertIn("long in a buzz cut", out[6])
out = run(sex="male", hair_length=("fixed", "", "long"),
hair_style=("allow_list", "buzz cut", "(none)"))
self.assertIn("in a buzz cut", out[6])
def test_incompatible_bundled_body_allow_list_keeps_description(self):
for key, label, pin in (("chest", "barrel", 13), ("shoulders", "broad", 12)):
with self.subTest(key=key):
out = run(sex="male", body_type=("fixed", "", "slim"),
**{key: ("allow_list", label, "(none)")})
expected = "a barrel chest" if key == "chest" else "broad shoulders"
self.assertEqual(out[pin], expected)
def test_mixed_bundled_allow_list_filters_incompatible_choices(self):
for seed in range(20):
out = run(seed=seed, sex="male", hair_style=("fixed", "", "buzz cut"),
hair_length=("allow_list", "long, short", "(none)"),
body_type=("fixed", "", "slim"),
chest=("allow_list", "barrel, flat", "(none)"),
shoulders=("allow_list", "broad, narrow", "(none)"))
self.assertIn("short in a buzz cut", out[6])
self.assertNotIn("very short", out[6])
self.assertEqual(out[13], "a flat chest")
self.assertEqual(out[12], "narrow shoulders")
def test_custom_paired_allow_list_remains_literal(self):
out = run(sex="male", body_type=("fixed", "", "slim"),
chest=("allow_list", "custom chest", "(none)"))
self.assertEqual(out[13], "custom chest")
class TestSegmentNodes(unittest.TestCase):
def _node(self, node_id):