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.
676 lines
31 KiB
Python
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
|