Files
bradsec-ComfyUI_RandomPerson/nodes.py
T
BRADSEC 205681e121 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
2026-10-31 13:13:15 +10:00

776 lines
37 KiB
Python

"""
Random Person Description Node for ComfyUI
==========================================
Produces a clean, consistently structured person description, e.g.:
34 year old Brazilian female, heart face, warm beige complexion,
hazel eyes, button nose, bow-shaped lips, auburn long hair worn
loose waves, hourglass build
Each category also has its own individual output pin.
Per-category MODE:
"random" - pick any entry from the (sex-appropriate) full list
"allow_list" - comma-separated label subset (blank = all)
"fixed" - use the exact label selected in the dropdown
"off" - omit this category entirely
Age uses numeric age_min / age_max (0 = no bound) or age_fixed.
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
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
MODES = ["random", "allow_list", "fixed", "off"]
SEX_MODES = ["random", "male", "female"]
AGE_MODES = ["random", "fixed", "off"]
# -- Data helpers --------------------------------------------------------------
@lru_cache(maxsize=None)
def load(subdir, filename):
# Parsed once per process. Callers must treat the result as read-only.
# Matches the documented "edit data, restart ComfyUI" workflow.
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)
def _labels(data):
"""Extract label list from a JSON data list."""
return [item["label"] if isinstance(item, dict) else item for item in data]
def _union(*lists):
"""Merge multiple label lists, deduplicated and sorted."""
seen, out = set(), []
for lst in lists:
for x in lst:
if x not in seen:
seen.add(x)
out.append(x)
return sorted(out)
# Build dropdown option lists once at import time.
_NAT_LABELS = ["(none)"] + _labels(load_shared("nationality.json"))
_COMP_LABELS = ["(none)"] + _labels(load_shared("complexion.json"))
_EYES_LABELS = ["(none)"] + _labels(load_shared("eyes.json"))
# Sex-gated lists: union of male + female so the dropdown is valid for any sex.
_HAIR_COLOR_LABELS = ["(none)"] + _union(_labels(load_sex("male", "hair_color.json")),
_labels(load_sex("female", "hair_color.json")))
_HAIR_STYLE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "hair_style.json")),
_labels(load_sex("female", "hair_style.json")))
_HAIR_LENGTH_LABELS = ["(none)"] + _union(_labels(load_sex("male", "hair_length.json")),
_labels(load_sex("female", "hair_length.json")))
_BODY_TYPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "body_type.json")),
_labels(load_sex("female", "body_type.json")))
_FACE_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "face_shape.json")),
_labels(load_sex("female", "face_shape.json")))
_NOSE_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "nose_shape.json")),
_labels(load_sex("female", "nose_shape.json")))
_MOUTH_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "mouth_shape.json")),
_labels(load_sex("female", "mouth_shape.json")))
_FACE_FEATURE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "face_feature.json")),
_labels(load_sex("female", "face_feature.json")))
_FACIAL_HAIR_LABELS = ["(none)"] + _union(_labels(load_sex("male", "facial_hair.json")),
_labels(load_sex("female", "facial_hair.json")))
_MAKEUP_LABELS = ["(none)"] + _union(_labels(load_sex("male", "makeup.json")),
_labels(load_sex("female", "makeup.json")))
_CLOTHING_LABELS = ["(none)"] + _union(_labels(load_sex("male", "clothing.json")),
_labels(load_sex("female", "clothing.json")))
_FOOTWEAR_LABELS = ["(none)"] + _union(_labels(load_sex("male", "footwear.json")),
_labels(load_sex("female", "footwear.json")))
_SHOULDERS_LABELS = ["(none)"] + _union(_labels(load_sex("male", "shoulders.json")),
_labels(load_sex("female", "shoulders.json")))
_CHEST_LABELS = ["(none)"] + _union(_labels(load_sex("male", "chest.json")),
_labels(load_sex("female", "chest.json")))
_BUST_SIZE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "bust_size.json")),
_labels(load_sex("female", "bust_size.json")))
_BUST_SHAPE_LABELS = ["(none)"] + _union(_labels(load_sex("male", "bust_shape.json")),
_labels(load_sex("female", "bust_shape.json")))
# Shared (sex-agnostic) lists.
_SKIN_TEXTURE_LABELS = ["(none)"] + _labels(load_shared("skin_texture.json"))
_EYE_SHAPE_LABELS = ["(none)"] + _labels(load_shared("eye_shape.json"))
_EYEBROWS_LABELS = ["(none)"] + _labels(load_shared("eyebrows.json"))
_ACCESSORIES_LABELS = ["(none)"] + _labels(load_shared("accessories.json"))
_EXPRESSION_LABELS = ["(none)"] + _labels(load_shared("expression.json"))
# Per-category spec drives BOTH API wrappers so they cannot drift.
# (key, fixed_label_list, allow_list_placeholder)
CATEGORY_SPECS = [
("nationality", _NAT_LABELS, "e.g. American, Japanese, Italian"),
("complexion", _COMP_LABELS, "e.g. fair, olive, deep brown"),
("skin_texture", _SKIN_TEXTURE_LABELS, "e.g. smooth, freckled, wrinkled"),
("eyes", _EYES_LABELS, "e.g. deep brown, ice blue, hazel"),
("eye_shape", _EYE_SHAPE_LABELS, "e.g. almond, hooded, monolid, round"),
("eyebrows", _EYEBROWS_LABELS, "e.g. thick, arched, straight, bushy"),
("face_shape", _FACE_SHAPE_LABELS, "e.g. oval, square, heart"),
("nose_shape", _NOSE_SHAPE_LABELS, "e.g. straight nose, broad nose, snub nose"),
("mouth_shape", _MOUTH_SHAPE_LABELS, "e.g. full lips, thin lips, wide mouth"),
("face_feature", _FACE_FEATURE_LABELS, "e.g. high cheekbones, dimples, freckles"),
("hair_color", _HAIR_COLOR_LABELS, "e.g. jet black, auburn, platinum blonde"),
("hair_style", _HAIR_STYLE_LABELS, "e.g. bun, braids, pixie cut"),
("hair_length", _HAIR_LENGTH_LABELS, "e.g. short, medium, long"),
("facial_hair", _FACIAL_HAIR_LABELS, "e.g. full beard, stubble, goatee"),
("body_type", _BODY_TYPE_LABELS, "e.g. athletic, slim, curvy, stocky"),
("shoulders", _SHOULDERS_LABELS, "e.g. broad, narrow, sloping"),
("chest", _CHEST_LABELS, "e.g. broad, muscular, barrel"),
("bust_size", _BUST_SIZE_LABELS, "e.g. petite, average, full"),
("bust_shape", _BUST_SHAPE_LABELS, "e.g. round, teardrop, natural"),
("expression", _EXPRESSION_LABELS, "e.g. soft smile, serious, confident"),
("accessories", _ACCESSORIES_LABELS, "e.g. glasses, hoop earrings, nose ring"),
("makeup", _MAKEUP_LABELS, "e.g. natural makeup, bold lipstick, smokey eye"),
("clothing", _CLOTHING_LABELS, "e.g. t-shirt, leather jacket, blazer"),
("footwear", _FOOTWEAR_LABELS, "e.g. sneakers, boots, sandals, heels"),
]
# Categories whose data lives under data/<sex>/ rather than data/shared/.
SEX_GATED = {"hair_color", "hair_style", "hair_length", "body_type", "face_shape",
"nose_shape", "mouth_shape", "face_feature", "facial_hair", "makeup",
"shoulders", "chest", "bust_size", "bust_shape", "clothing", "footwear"}
# Optional "flair" categories default to mode "off" so the base description stays
# clean and realistic; the user opts them in per category. Core identity
# categories (nationality, complexion, eyes, face/nose/mouth shape, hair, body)
# default to "random".
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"
# Tooltips (shared by both APIs).
_SEED_TIP = "Seed for reproducibility. With randomize off, the same seed reproduces the same person."
_RAND_TIP = "On: pick a new random person every run, ignoring the seed widget. Off: use the seed."
_SEX_TIP = "random, male, or female. male/female filter the sex-specific dropdowns in the UI."
_MODE_TIP = "How this attribute is chosen: random (any), allow_list (subset you list), fixed (the dropdown value), off (omit it)."
_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 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."
RETURN_NAMES = ("description", "sex", "age", "nationality", "complexion",
"face", "hair", "facial_hair", "body_type",
"expression", "accessories", "makeup",
"shoulders", "chest", "bust", "clothing", "footwear", "seed")
# -- Selection logic -----------------------------------------------------------
def item_matches(item, token):
"""
Fuzzy multi-field match (case-insensitive).
Priority: exact (2) > token-in-field or field-in-token (1) > no match (0).
"""
if isinstance(item, dict):
fields = [item.get("label", ""), item.get("descriptor", ""),
item.get("from", ""), item.get("description", "")]
else:
fields = [str(item)]
fields = [f.lower() for f in fields if f]
for field in fields:
if token == field: return 2
for field in fields:
if token in field: return 1 # "america" in "american"
if field in token: return 1 # "french" in "french canadian"
return 0
def _best_match(full_list, token):
"""Return the best-matching item for token, or None."""
scored = [(item_matches(item, token), item) for item in full_list]
scored = [(s, item) for s, item in scored if s > 0]
if not scored:
return None
best_score = max(s for s, _ in scored)
candidates = [item for s, item in scored if s == best_score]
# Among equal-score candidates, prefer shortest label (most specific match).
if isinstance(candidates[0], dict):
candidates.sort(key=lambda x: len(x.get("label", "")))
return candidates[0]
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
if mode == "fixed" and fixed_val.strip() and fixed_val.strip() != "(none)":
token = fixed_val.strip().lower()
match = _best_match(full_list, token)
if match is not None:
return match
print(f"[RandomPersonNode] WARNING: fixed '{fixed_val}' not found - using random.")
if mode == "allow_list" and allow_str.strip():
tokens = [s.strip() for s in allow_str.split(",") if s.strip()]
matched_items, custom_strings = [], []
for t in tokens:
tl = t.lower()
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:
custom_strings.append(t) # not in JSON - treat as literal custom value
seen, deduped = set(), []
for item in matched_items:
key = id(item)
if key not in seen:
seen.add(key)
deduped.append(item)
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.")
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:
age_fixed_val = max(age_fixed_val, 21)
exact = [a for a in all_ages if a["age"] == age_fixed_val]
if exact:
return exact[0]
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
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.")
pool = all_ages
return rng.choice(pool)
def d(item, key="description"):
"""Extract a string from a dict item, or the item itself."""
return item.get(key, "") if isinstance(item, dict) else str(item)
def nat_text(item):
"""Nationality display text. Falls back when a custom allow_list value has
no 'descriptor' key (custom items carry only label/description)."""
if not item:
return ""
if isinstance(item, dict):
return item.get("descriptor") or item.get("description") or item.get("label") or ""
return str(item)
def _bust_phrase(size_str, shape_str):
"""Combine bust size and shape into one descriptor, or '' if neither."""
lead = size_str or shape_str
if not lead:
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} {lead} bust"
# -- Description builder -------------------------------------------------------
def build_description(sex, nat, age, skin_texture,
face_shape, complexion, eyes, nose_shape, mouth_shape, face_feature,
hair_color, hair_style, hair_length, facial_hair,
body, expression, accessories, makeup, extra_attributes="",
eye_shape=None, eyebrows=None,
shoulders=None, chest=None, bust_size=None, bust_shape=None,
clothing=None, footwear=None):
"""Produce a single comma-separated descriptor string."""
parts = []
def add(item, fmt="{}"):
"""Append a category's description if it resolves to non-empty text."""
s = d(item).strip() if item else ""
if s:
parts.append(fmt.format(s))
# Opener: "34 year old Brazilian female". Sex is shown only alongside an age
# or nationality, so a face-only fragment does not start with a bare "female".
age_str = f"{age['age']} year old" if age else None
nat_str = nat_text(nat) or None
sex_str = sex if sex else None
opener_parts = [p for p in [age_str, nat_str] if p]
if opener_parts:
if sex_str:
opener_parts.append(sex_str)
parts.append(" ".join(opener_parts))
add(face_shape, "{} face")
add(complexion, "{} complexion")
add(skin_texture)
shape_str = d(eye_shape).strip() if eye_shape else ""
if eyes or shape_str:
color_str = d(eyes).strip() if eyes else ""
base = (color_str if "eyes" in color_str.lower() else f"{color_str} eyes") if color_str else "eyes"
parts.append(f"{shape_str} {base}" if shape_str else base)
add(eyebrows)
add(nose_shape)
add(mouth_shape)
add(face_feature)
# Hair: "auburn long hair worn loose waves"
hair_color_str = d(hair_color) if hair_color else None
hair_length_str = d(hair_length) if hair_length else None
hair_style_str = d(hair_style) if hair_style else None
if hair_color_str or hair_length_str or hair_style_str:
pre_hair = " ".join(p for p in [hair_color_str, hair_length_str] if p)
if pre_hair and hair_style_str:
parts.append(f"{pre_hair} hair worn {hair_style_str}")
elif pre_hair:
parts.append(f"{pre_hair} hair")
elif hair_style_str:
parts.append(f"hair worn {hair_style_str}")
add(facial_hair)
if body:
build_str = d(body)
parts.append(build_str if build_str.lower().endswith("build") else f"{build_str} build")
add(shoulders)
add(chest)
bust = _bust_phrase(d(bust_size).strip() if bust_size else "",
d(bust_shape).strip() if bust_shape else "")
if bust:
parts.append(bust)
add(expression)
add(accessories)
add(makeup)
add(clothing)
add(footwear)
if extra_attributes and extra_attributes.strip():
for attr in extra_attributes.split(","):
attr = attr.strip().strip(".")
if attr:
parts.append(attr)
return ", ".join(parts)
# -- Shared core (called by both API wrappers) ---------------------------------
def generate_person(seed, randomize, sex, category_args,
age_mode, age_min, age_max, age_fixed, extra_attributes):
"""
category_args: dict keyed by category prefix with (mode, allow_list, fixed)
tuples for each entry in CATEGORY_SPECS. Returns the output tuple matching
RETURN_NAMES.
"""
if randomize:
seed = random.randint(0, 0xFFFFFFFFFFFFFFFF)
# Local RNG: never seed the process-wide random module; other nodes share it.
rng = random.Random(seed)
resolved_sex = sex if sex in ("male", "female") else rng.choice(["male", "female"])
def load_list(key):
sub = resolved_sex if key in SEX_GATED else "shared"
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]
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(
resolved_sex, p["nationality"], age, p["skin_texture"],
p["face_shape"], p["complexion"], p["eyes"], p["nose_shape"], p["mouth_shape"], p["face_feature"],
p["hair_color"], p["hair_style"], p["hair_length"], p["facial_hair"],
p["body_type"], p["expression"], p["accessories"], p["makeup"],
extra_attributes, eye_shape=p["eye_shape"], eyebrows=p["eyebrows"],
shoulders=p["shoulders"], chest=p["chest"],
bust_size=p["bust_size"], bust_shape=p["bust_shape"],
clothing=p["clothing"], footwear=p["footwear"])
def dd(key, sub_key="description"):
item = p[key]
return d(item, sub_key) if item else ""
out_age = str(age["age"]) if age else ""
out_nat = nat_text(p["nationality"])
out_comp = ", ".join(filter(None, [dd("complexion"), dd("skin_texture")]))
out_hair = " ".join(filter(None, [dd("hair_color"), dd("hair_length"), dd("hair_style")]))
def _eyes_str():
shape = dd("eye_shape")
color = dd("eyes")
if not (shape or color):
return ""
base = (color if "eyes" in color.lower() else f"{color} eyes") if color else "eyes"
return f"{shape} {base}" if shape else base
out_face = ", ".join(filter(None, [
f"{dd('face_shape')} face" if dd("face_shape") else "",
_eyes_str(),
dd("eyebrows"),
dd("nose_shape"),
dd("mouth_shape"),
dd("face_feature"),
]))
out_bust = _bust_phrase(dd("bust_size"), dd("bust_shape"))
return (description, resolved_sex, out_age, out_nat, out_comp,
out_face, out_hair, dd("facial_hair"), dd("body_type"),
dd("expression"), dd("accessories"), dd("makeup"),
dd("shoulders"), dd("chest"), out_bust, dd("clothing"), dd("footwear"), seed)
def _collect_category_args(kwargs, keys=None):
"""Pull the (mode, allow_list, fixed) triple for each category from kwargs.
When `keys` is given, only those categories are read from kwargs; every
other category is forced "off". This lets a segment node expose a subset of
widgets while the shared core still receives a full category map.
"""
active = set(keys) if keys is not None else None
out = {}
for key, _, _ in CATEGORY_SPECS:
if (active is None or key in active) and f"{key}_mode" in kwargs:
out[key] = (kwargs[f"{key}_mode"],
kwargs[f"{key}_allow_list"],
kwargs[f"{key}_fixed"])
else:
out[key] = ("off", "", "(none)")
return out
def person_dict(result):
"""Map a generate_person result tuple to {return_name: value}."""
return dict(zip(RETURN_NAMES, result))
# -- V1 node factory -----------------------------------------------------------
_ALL_KEYS = [k for k, _, _ in CATEGORY_SPECS]
# (node_id, display_name, category keys, include_age, output pin names)
NODE_SPECS = [
("RandomPersonNode", "Random Person AIO (All-In-One)", _ALL_KEYS, True, RETURN_NAMES),
("RandomPersonIdentity", "Random Person: Identity",
["nationality", "complexion", "skin_texture"], True,
("description", "sex", "age", "nationality", "complexion", "seed")),
("RandomPersonFace", "Random Person: Face",
["face_shape", "eyes", "eye_shape", "eyebrows", "nose_shape",
"mouth_shape", "face_feature", "expression"], False,
("description", "sex", "face", "expression", "seed")),
("RandomPersonHair", "Random Person: Hair",
["hair_color", "hair_style", "hair_length", "facial_hair"], False,
("description", "sex", "hair", "facial_hair", "seed")),
("RandomPersonBody", "Random Person: Body",
["body_type", "shoulders", "chest", "bust_size", "bust_shape"], False,
("description", "sex", "body_type", "shoulders", "chest", "bust", "seed")),
("RandomPersonStyle", "Random Person: Style",
["clothing", "accessories", "makeup", "footwear"], False,
("description", "sex", "clothing", "accessories", "makeup", "footwear", "seed")),
]
_NODE_DESCRIPTION = "Generate a randomised, structured physical person description for image prompts."
# Per-node display name for the first ("description") output pin. The internal
# lookup key stays "description"; only the shown pin label changes, so it is
# clear which node a full description came from.
_FIRST_PIN_LABEL = {
"RandomPersonNode": "full_description",
"RandomPersonIdentity": "full_identity_description",
"RandomPersonFace": "full_face_description",
"RandomPersonHair": "full_hair_description",
"RandomPersonBody": "full_body_description",
"RandomPersonStyle": "full_style_description",
}
def _display_names(node_id, output_names):
"""Output names as shown in the UI: first pin relabelled per node, rest unchanged."""
first = _FIRST_PIN_LABEL.get(node_id, output_names[0])
return (first,) + tuple(output_names[1:])
def make_v1_node(node_id, display_name, keys, include_age, output_names):
"""Build a ComfyUI V1 node class exposing only `keys` and returning `output_names`."""
key_set = set(keys)
display = _display_names(node_id, output_names)
class _RandomPersonV1:
CATEGORY = "Random Person"
FUNCTION = "generate"
OUTPUT_NODE = False
DESCRIPTION = _NODE_DESCRIPTION
RETURN_NAMES = display
RETURN_TYPES = tuple("INT" if n == "seed" else "STRING" for n in output_names)
@classmethod
def INPUT_TYPES(cls):
required = {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "tooltip": _SEED_TIP}),
"randomize": ("BOOLEAN", {"default": True, "tooltip": _RAND_TIP}),
"sex": (SEX_MODES, {"default": "random", "tooltip": _SEX_TIP}),
}
for key, fixed_labels, placeholder in CATEGORY_SPECS:
if key not in key_set:
continue
required[f"{key}_mode"] = (MODES, {"default": _mode_default(key), "tooltip": _MODE_TIP})
required[f"{key}_allow_list"] = ("STRING", {"default": "", "multiline": False,
"placeholder": placeholder, "tooltip": _ALLOW_TIP})
required[f"{key}_fixed"] = (fixed_labels, {"default": "(none)", "tooltip": _FIXED_TIP})
if include_age:
required["age_mode"] = (AGE_MODES, {"default": "random", "tooltip": _AGEMODE_TIP})
required["age_min"] = ("INT", {"default": 0, "min": 0, "max": 90, "tooltip": _AGEMIN_TIP})
required["age_max"] = ("INT", {"default": 0, "min": 0, "max": 90, "tooltip": _AGEMAX_TIP})
required["age_fixed"] = ("INT", {"default": 0, "min": 0, "max": 90, "tooltip": _AGEFIX_TIP})
required["extra_attributes"] = ("STRING", {"default": "", "multiline": True,
"placeholder": "Add any extra details to pass into the description, e.g. wearing a red scarf, holding a coffee, tattoo on left arm",
"tooltip": _EXTRA_TIP})
return {"required": required}
def generate(self, seed, randomize, sex, **kwargs):
age_mode = kwargs.get("age_mode", "off") if include_age else "off"
age_min = kwargs.get("age_min", 0)
age_max = kwargs.get("age_max", 0)
age_fixed = kwargs.get("age_fixed", 0)
extra = kwargs.get("extra_attributes", "")
cat_args = _collect_category_args(kwargs, keys)
result = generate_person(seed, randomize, sex, cat_args,
age_mode, age_min, age_max, age_fixed, extra)
dmap = person_dict(result)
return tuple(dmap[name] for name in output_names)
@classmethod
def IS_CHANGED(cls, randomize, seed, **kwargs):
return float("nan") if randomize else seed
_RandomPersonV1.__name__ = node_id
_RandomPersonV1.__qualname__ = node_id
return _RandomPersonV1
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
for _spec in NODE_SPECS:
_cls = make_v1_node(*_spec)
NODE_CLASS_MAPPINGS[_spec[0]] = _cls
NODE_DISPLAY_NAME_MAPPINGS[_spec[0]] = _spec[1]
# Backwards-compatible alias: tests and external imports reference this name.
RandomPersonNode = NODE_CLASS_MAPPINGS["RandomPersonNode"]
# -- V3 node factory (import-guarded comfy_api schema) -------------------------
try:
_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)
display_pins = _display_names(node_id, output_names)
class _RandomPersonV3(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
inputs = [
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, tooltip=_SEED_TIP),
io.Boolean.Input("randomize", default=True, tooltip=_RAND_TIP),
io.Combo.Input("sex", options=SEX_MODES, default="random", tooltip=_SEX_TIP),
]
for key, fixed_labels, placeholder in CATEGORY_SPECS:
if key not in key_set:
continue
inputs += [
io.Combo.Input(f"{key}_mode", options=MODES, default=_mode_default(key), tooltip=_MODE_TIP),
io.String.Input(f"{key}_allow_list", default="", multiline=False,
placeholder=placeholder, tooltip=_ALLOW_TIP),
io.Combo.Input(f"{key}_fixed", options=fixed_labels, default="(none)", tooltip=_FIXED_TIP),
]
if include_age:
inputs += [
io.Combo.Input("age_mode", options=AGE_MODES, default="random", tooltip=_AGEMODE_TIP),
io.Int.Input("age_min", default=0, min=0, max=90, tooltip=_AGEMIN_TIP),
io.Int.Input("age_max", default=0, min=0, max=90, tooltip=_AGEMAX_TIP),
io.Int.Input("age_fixed", default=0, min=0, max=90, tooltip=_AGEFIX_TIP),
]
inputs.append(
io.String.Input("extra_attributes", default="", multiline=True,
placeholder="Add any extra details to pass into the description, e.g. wearing a red scarf, holding a coffee, tattoo on left arm", tooltip=_EXTRA_TIP))
outputs = []
for name in display_pins:
if name == "seed":
outputs.append(io.Int.Output(id="seed", display_name="seed", tooltip=_SEED_TIP))
else:
outputs.append(io.String.Output(id=name, display_name=name))
return io.Schema(
node_id=node_id,
display_name=display_name,
category="Random Person",
description=_NODE_DESCRIPTION,
inputs=inputs,
outputs=outputs,
)
@classmethod
def fingerprint_inputs(cls, randomize, seed, **kwargs):
return float("nan") if randomize else seed
@classmethod
def execute(cls, seed, randomize, sex, **kwargs) -> io.NodeOutput:
age_mode = kwargs.get("age_mode", "off") if include_age else "off"
age_min = kwargs.get("age_min", 0)
age_max = kwargs.get("age_max", 0)
age_fixed = kwargs.get("age_fixed", 0)
extra = kwargs.get("extra_attributes", "")
cat_args = _collect_category_args(kwargs, keys)
result = generate_person(seed, randomize, sex, cat_args,
age_mode, age_min, age_max, age_fixed, extra)
dmap = person_dict(result)
return io.NodeOutput(*(dmap[name] for name in output_names))
_RandomPersonV3.__name__ = node_id + "V3"
_RandomPersonV3.__qualname__ = _RandomPersonV3.__name__
return _RandomPersonV3
_V3_NODES = [_make_v3_node(*spec) for spec in NODE_SPECS]
class RandomPersonExtension(ComfyExtension):
async def get_node_list(self):
return _V3_NODES
async def comfy_entrypoint() -> "RandomPersonExtension":
return RandomPersonExtension()