From 205681e1217256da5ea04c92f0762a5d85d58690 Mon Sep 17 00:00:00 2001 From: BRADSEC <7948876+bradsec@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:13:15 +1000 Subject: [PATCH] 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 " 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 --- README.md | 12 +- __init__.py | 19 ++-- data/female/body_type.json | 20 ++-- data/female/bust_shape.json | 3 +- data/female/bust_size.json | 4 +- data/female/clothing.json | 5 +- data/female/face_feature.json | 22 +--- data/female/facial_hair.json | 10 +- data/female/footwear.json | 3 +- data/female/hair_color.json | 14 +-- data/female/hair_style.json | 32 +++--- data/female/makeup.json | 10 +- data/female/mouth_shape.json | 3 +- data/female/nose_shape.json | 3 - data/female/shoulders.json | 2 +- data/male/body_type.json | 22 ++-- data/male/clothing.json | 5 +- data/male/face_feature.json | 18 +-- data/male/footwear.json | 4 +- data/male/hair_color.json | 12 +- data/male/hair_style.json | 28 ++--- data/male/makeup.json | 14 ++- data/male/mouth_shape.json | 3 +- data/male/nose_shape.json | 2 - data/shared/accessories.json | 10 +- data/shared/complexion.json | 9 +- data/shared/expression.json | 10 +- data/shared/eye_shape.json | 10 +- data/shared/eyebrows.json | 4 - data/shared/eyes.json | 8 +- data/shared/face_feature.json | 17 +-- data/shared/nationality.json | 2 +- data/shared/skin_texture.json | 22 ++-- js/random_person_node.js | 67 ++++++----- nodes.py | 142 +++++++++++++++++++---- pyproject.toml | 2 +- test_random_person.py | 207 ++++++++++++++++++++++++++++++++-- 37 files changed, 502 insertions(+), 278 deletions(-) diff --git a/README.md b/README.md index 2742335..e0a8730 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/__init__.py b/__init__.py index d4ce22b..4ce42c1 100644 --- a/__init__.py +++ b/__init__.py @@ -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"] diff --git a/data/female/body_type.json b/data/female/body_type.json index 34eba05..333378d 100644 --- a/data/female/body_type.json +++ b/data/female/body_type.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/female/bust_shape.json b/data/female/bust_shape.json index 12ea14f..8e7c0a6 100644 --- a/data/female/bust_shape.json +++ b/data/female/bust_shape.json @@ -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" } ] diff --git a/data/female/bust_size.json b/data/female/bust_size.json index 8222e3b..829c52b 100644 --- a/data/female/bust_size.json +++ b/data/female/bust_size.json @@ -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" } ] diff --git a/data/female/clothing.json b/data/female/clothing.json index 8d1fe00..df9a5d4 100644 --- a/data/female/clothing.json +++ b/data/female/clothing.json @@ -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"} ] diff --git a/data/female/face_feature.json b/data/female/face_feature.json index 665f711..7f2e953 100644 --- a/data/female/face_feature.json +++ b/data/female/face_feature.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/female/facial_hair.json b/data/female/facial_hair.json index f65ff5a..7b3dc7f 100644 --- a/data/female/facial_hair.json +++ b/data/female/facial_hair.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/female/footwear.json b/data/female/footwear.json index d52c52e..5b05087 100644 --- a/data/female/footwear.json +++ b/data/female/footwear.json @@ -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"} ] diff --git a/data/female/hair_color.json b/data/female/hair_color.json index becf846..91a9006 100644 --- a/data/female/hair_color.json +++ b/data/female/hair_color.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/female/hair_style.json b/data/female/hair_style.json index 6b597aa..38c0487 100644 --- a/data/female/hair_style.json +++ b/data/female/hair_style.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/female/makeup.json b/data/female/makeup.json index e8eac40..3bb58ad 100644 --- a/data/female/makeup.json +++ b/data/female/makeup.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/female/mouth_shape.json b/data/female/mouth_shape.json index 2eca72e..d74e0f1 100644 --- a/data/female/mouth_shape.json +++ b/data/female/mouth_shape.json @@ -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" } ] diff --git a/data/female/nose_shape.json b/data/female/nose_shape.json index 9083822..da5a33f 100644 --- a/data/female/nose_shape.json +++ b/data/female/nose_shape.json @@ -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" } diff --git a/data/female/shoulders.json b/data/female/shoulders.json index ad00310..04d7f9e 100644 --- a/data/female/shoulders.json +++ b/data/female/shoulders.json @@ -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" } ] diff --git a/data/male/body_type.json b/data/male/body_type.json index de6cef8..8bed211 100644 --- a/data/male/body_type.json +++ b/data/male/body_type.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/male/clothing.json b/data/male/clothing.json index 9992a0c..eb33f85 100644 --- a/data/male/clothing.json +++ b/data/male/clothing.json @@ -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"} ] diff --git a/data/male/face_feature.json b/data/male/face_feature.json index 5aa4126..11cb887 100644 --- a/data/male/face_feature.json +++ b/data/male/face_feature.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/male/footwear.json b/data/male/footwear.json index 2afcc1d..95307aa 100644 --- a/data/male/footwear.json +++ b/data/male/footwear.json @@ -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"} ] diff --git a/data/male/hair_color.json b/data/male/hair_color.json index 4973fe7..d453608 100644 --- a/data/male/hair_color.json +++ b/data/male/hair_color.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/male/hair_style.json b/data/male/hair_style.json index b55ec1e..07df522 100644 --- a/data/male/hair_style.json +++ b/data/male/hair_style.json @@ -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" } -] \ No newline at end of file +] diff --git a/data/male/makeup.json b/data/male/makeup.json index 359eb94..45443de 100644 --- a/data/male/makeup.json +++ b/data/male/makeup.json @@ -2,5 +2,17 @@ { "label": "none", "description": "" + }, + { + "label": "subtle concealer", + "description": "subtle concealer" + }, + { + "label": "eyeliner", + "description": "black eyeliner" + }, + { + "label": "red lipstick", + "description": "red lipstick" } -] \ No newline at end of file +] diff --git a/data/male/mouth_shape.json b/data/male/mouth_shape.json index a4e3852..4df4ca2 100644 --- a/data/male/mouth_shape.json +++ b/data/male/mouth_shape.json @@ -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" } ] diff --git a/data/male/nose_shape.json b/data/male/nose_shape.json index e551712..ec58181 100644 --- a/data/male/nose_shape.json +++ b/data/male/nose_shape.json @@ -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" }, diff --git a/data/shared/accessories.json b/data/shared/accessories.json index 94af265..7502bec 100644 --- a/data/shared/accessories.json +++ b/data/shared/accessories.json @@ -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" } ] diff --git a/data/shared/complexion.json b/data/shared/complexion.json index 6ac0d1c..74b558f 100644 --- a/data/shared/complexion.json +++ b/data/shared/complexion.json @@ -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"} ] diff --git a/data/shared/expression.json b/data/shared/expression.json index bd4706d..3b4ced2 100644 --- a/data/shared/expression.json +++ b/data/shared/expression.json @@ -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", diff --git a/data/shared/eye_shape.json b/data/shared/eye_shape.json index 4993005..60c3e8e 100644 --- a/data/shared/eye_shape.json +++ b/data/shared/eye_shape.json @@ -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" } ] diff --git a/data/shared/eyebrows.json b/data/shared/eyebrows.json index d7b1856..aebd411 100644 --- a/data/shared/eyebrows.json +++ b/data/shared/eyebrows.json @@ -23,10 +23,6 @@ "label": "bushy", "description": "bushy eyebrows" }, - { - "label": "full natural", - "description": "full natural brows" - }, { "label": "sparse", "description": "sparse brows" diff --git a/data/shared/eyes.json b/data/shared/eyes.json index 7269719..2205700 100644 --- a/data/shared/eyes.json +++ b/data/shared/eyes.json @@ -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", diff --git a/data/shared/face_feature.json b/data/shared/face_feature.json index 2803c71..943dd59 100644 --- a/data/shared/face_feature.json +++ b/data/shared/face_feature.json @@ -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" -] \ No newline at end of file +] diff --git a/data/shared/nationality.json b/data/shared/nationality.json index 20f9868..631f1d4 100644 --- a/data/shared/nationality.json +++ b/data/shared/nationality.json @@ -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" } ] diff --git a/data/shared/skin_texture.json b/data/shared/skin_texture.json index 988d491..5700af1 100644 --- a/data/shared/skin_texture.json +++ b/data/shared/skin_texture.json @@ -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" } ] diff --git a/js/random_person_node.js b/js/random_person_node.js index 9a10f84..17a74c7 100644 --- a/js/random_person_node.js +++ b/js/random_person_node.js @@ -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); diff --git a/nodes.py b/nodes.py index aecf87a..790b966 100644 --- a/nodes.py +++ b/nodes.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index bed2fd7..51c32a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [] diff --git a/test_random_person.py b/test_random_person.py index 9e5bfb5..30d9e81 100644 --- a/test_random_person.py +++ b/test_random_person.py @@ -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):