""" 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// 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