Files
BRADSEC 1ce4dc851f Use local RNG instance, widen seed to 64-bit, add age_mode tooltip (review R1-R4)
random.seed() on the shared module polluted process-wide state; a
per-call random.Random(seed) keeps same-seed output identical while
isolating other nodes. Seed max now matches the standard ComfyUI
0xffffffffffffffff range on both APIs. Bump to 1.3.1.
2026-07-03 10:50:47 +10:00

676 lines
31 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.
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.
"""
import json
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.
with open(os.path.join(DATA_DIR, subdir, filename), encoding="utf-8") as f:
return json.load(f)
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"}
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."
_AGEMAX_TIP = "Maximum age. 0 = no upper bound (defaults to 90). Minimum is always 21."
_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):
"""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_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:
return rng.choice(pool)
print(f"[RandomPersonNode] WARNING: allow_list '{allow_str}' matched nothing - using random.")
return rng.choice(full_list)
def resolve_age(mode, age_min, age_max, age_fixed_val, all_ages, rng=random):
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
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.")
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 = {}
for key, _, _ in CATEGORY_SPECS:
mode, allow, fixed = category_args[key]
p[key] = resolve(mode, load_list(key), allow, fixed, rng=rng)
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:
from comfy_api.v0_0_2 import io, 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()
except ImportError:
# Older ComfyUI without comfy_api: V1 mappings above are the only path.
pass