Files
lord-lethris-ComfyUI-RPG-Ch…/rpg_characters_node.py
2026-09-10 17:19:30 +01:00

527 lines
20 KiB
Python

# ComfyUI-RPG-Characters - RPG Character Selector with DNA Locker
# Author: Lord Lethris
import hashlib
import json
import re
import secrets
import random
from .rpg_character_data.rpg_race_data import RACE_DATA
from .rpg_character_data.rpg_ethnicity_data import ETHNICITY_DATA
from .rpg_character_data.rpg_gender_data import GENDER_DATA
from .rpg_character_data.rpg_age_data import AGE_DATA
from .rpg_character_data.rpg_class_data import CLASS_DATA
from .rpg_character_data.rpg_hair_style_data import HAIR_STYLE_DATA
from .rpg_character_data.rpg_hair_colour_data import HAIR_COLOUR_DATA
from .rpg_character_data.rpg_beard_style_data import BEARD_STYLE_DATA
from .rpg_character_data.rpg_beard_colour_data import BEARD_COLOUR_DATA
from .rpg_character_data.rpg_clothes_style_data import CLOTHES_STYLE_DATA
from .rpg_character_data.rpg_augment_data import AUGMENT_DATA
from .rpg_character_data.rpg_emotion_data import EMOTION_DATA
from .rpg_character_data.rpg_scene_data import SCENE_DATA
VARIANT_PATTERN = re.compile(r"\{([^{}]+)\}")
def _race_overrides_ethnicity_visuals(race_entry):
"""Return visual trait categories explicitly controlled by the race."""
prompt = str(race_entry.get("prompt", "")).lower()
overrides = set()
if "skin tone" in prompt or "skin tones" in prompt or "skin color" in prompt or "skin colour" in prompt:
overrides.add("skin")
if re.search(r"\beyes?\b", prompt):
overrides.add("eyes")
return overrides
def _remove_ethnicity_visual_conflicts(text, overrides):
"""Keep ethnicity identity/facial traits, but yield explicit race traits.
Ethnicity data is deliberately lightweight prose rather than a structured
schema, so conflict removal is clause-based. The explicit ethnicity label
is always retained even when its first clause also contains skin/eye data.
"""
if not text or not overrides:
return text
clauses = [clause.strip() for clause in str(text).split(",")]
kept = []
skip_continuation = False
for index, clause in enumerate(clauses):
lower = clause.lower()
has_skin = bool(re.search(
r"\bskin(?:\s+tone|\s+tones|\s+color|\s+colour)?\b", lower
))
has_eyes = bool(re.search(
r"\beye(?:s|\s+(?:color|colour))?\b", lower
))
if skip_continuation:
# Eye colours often continue across comma-separated fragments:
# "... eye colors, typically brown, hazel, or green". Suppress
# those continuation fragments while preserving later substantive
# facial descriptors if a data entry happens to contain them.
substantive = r"\b(face|features?|jaw|nose|cheek|hair|build|stature|complexion|skin)\b"
if not re.search(substantive, lower):
continue
skip_continuation = False
if ("skin" in overrides and has_skin) or ("eyes" in overrides and has_eyes):
if index == 0:
identity = re.match(r"(\(Ethnicity[^)]*\))", clause, flags=re.IGNORECASE)
if identity:
kept.append(identity.group(1))
if "eyes" in overrides and has_eyes:
skip_continuation = True
continue
kept.append(clause)
result = ", ".join(c for c in kept if c)
result = re.sub(r"(^|,\s+)(?:and|with)\s+", r"\1", result, flags=re.IGNORECASE)
result = re.sub(r"\s{2,}", " ", result).strip(" ,")
return result
def _stable_seed(master_seed, locus_id, reroll_count=0):
"""Create a stable per-locus seed, including an optional reroll counter."""
payload = f"RPGCharacterSelector|{master_seed}|{locus_id}|reroll:{int(reroll_count)}".encode("utf-8")
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big")
def _parse_state(value):
if not value:
return {}
if isinstance(value, dict):
return value
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError, json.JSONDecodeError):
return {}
def _normalise_weights(weights, count):
values = []
for index in range(count):
try:
value = float(weights.get(str(index), weights.get(index, 0)))
except (TypeError, ValueError):
value = 0.0
values.append(max(0.0, value))
# V2.4 DNA is intentionally limited to the nearest three variants.
# This also sanitises older/stale workflow state that may contain more.
if count > 3:
keep = sorted(range(count), key=lambda i: values[i], reverse=True)[:3]
keep = set(keep)
values = [value if i in keep else 0.0 for i, value in enumerate(values)]
total = sum(values)
if total <= 0:
return None
return [value / total for value in values]
def _weighted_variant(options, weights):
"""Render an explicitly sculpted DNA locus.
The output deliberately uses the user's proposed '&' syntax.
The surrounding prompt parentheses (when present) are preserved, so:
{emerald|gold} Skin tone:1.3
can become:
((emerald:0.7) & (gold:0.3) Skin tone:1.3)
"""
normalised = _normalise_weights(weights, len(options))
if not normalised:
return None
active = [
(option, weight)
for option, weight in zip(options, normalised)
if weight > 0.0005
]
if not active:
return None
if len(active) == 1:
return active[0][0]
parts = [f"({option}:{weight:.3f}".rstrip("0").rstrip(".") + ")"
for option, weight in active]
return " & ".join(parts)
def resolve_prompt_variants_with_trace(text, master_seed, locus_prefix,
locks=None, weights=None, rerolls=None):
"""Resolve every {A|B|C} group and return a DNA trace for the UI."""
locks = locks or {}
weights = weights or {}
rerolls = rerolls or {}
selections = []
dna = []
group_index = 0
def replacer(match):
nonlocal group_index
raw = match.group(1)
options = [item.strip() for item in raw.split("|")]
locus_id = f"{locus_prefix}:{group_index}"
group_index += 1
if len(options) <= 1:
# Existing behaviour for literal braces is preserved.
choice = options[0] if options else ""
selections.append(choice)
return choice
# V2.4 no longer exposes lock state in the UI. Keep the parameter for
# backwards compatibility with older workflows, but never let stale
# lock entries prevent the new Re-roll/Sculpt workflow from working.
locked_value = None
custom_weights = weights.get(locus_id)
reroll_state = rerolls.get(locus_id, 0)
avoid_value = None
preview_value = None
if isinstance(reroll_state, dict):
try:
reroll_count = max(0, int(reroll_state.get("count", 0)))
except (TypeError, ValueError):
reroll_count = 0
avoid_value = reroll_state.get("avoid")
preview_value = reroll_state.get("preview")
else:
try:
reroll_count = max(0, int(reroll_state or 0))
except (TypeError, ValueError):
reroll_count = 0
mode = "random"
if locked_value in options:
choice = locked_value
mode = "locked"
rendered = choice
weight_map = None
elif isinstance(custom_weights, dict):
rendered = _weighted_variant(options, custom_weights)
if rendered is not None:
choice = " / ".join(
f"{option} {weight:.1%}"
for option, weight in zip(
options,
_normalise_weights(custom_weights, len(options))
)
if float(custom_weights.get(str(options.index(option)), 0) or 0) > 0
)
mode = "sculpted"
weight_map = _normalise_weights(custom_weights, len(options))
else:
rendered = None
weight_map = None
else:
# The Locker can choose a reroll immediately in the UI. Persist
# that preview in the reroll state so the next ComfyUI execution
# uses exactly what the user saw, rather than changing it again
# when Run is pressed. Older states without a preview continue
# to use the deterministic seed+reroll-counter path.
if preview_value in options:
choice = preview_value
else:
rng = random.Random(_stable_seed(master_seed, locus_id, reroll_count))
if avoid_value in options and len(options) > 1:
# A deliberate locus reroll should actually move to another
# variant when alternatives exist, rather than occasionally
# landing on the same value by chance.
alternatives = [option for option in options if option != avoid_value]
choice = rng.choice(alternatives)
else:
choice = rng.choice(options)
rendered = choice
weight_map = None
if mode != "sculpted":
weight_map = [1.0 if option == choice else 0.0 for option in options]
dna.append({
"id": locus_id,
"label": f"Variant {group_index}",
"options": options,
"selected": choice,
"weights": weight_map,
"mode": mode,
})
selections.append(choice)
return rendered
resolved = VARIANT_PATTERN.sub(replacer, text)
return resolved, selections, dna
class RPGCharacterSelector:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"race": (list(RACE_DATA.keys()),),
"ethnicity": (list(ETHNICITY_DATA.keys()),),
"gender": (list(GENDER_DATA.keys()),),
"age": (list(AGE_DATA.keys()),),
"character_class": (list(CLASS_DATA.keys()),),
"hair_style": (list(HAIR_STYLE_DATA.keys()),),
"hair_colour": (list(HAIR_COLOUR_DATA.keys()),),
"beard_style": (list(BEARD_STYLE_DATA.keys()),),
"beard_colour": (list(BEARD_COLOUR_DATA.keys()),),
"clothes_style": (list(CLOTHES_STYLE_DATA.keys()),),
"Augmentations": (list(AUGMENT_DATA.keys()),),
"emotion": (list(EMOTION_DATA.keys()),),
"scene": (list(SCENE_DATA.keys()),),
"dna_seed": ("INT", {
"default": -1,
"min": -1,
"max": 4294967295,
"step": 1,
}),
# Hidden execution revision. DNA Locker UI edits must invalidate
# ComfyUI's execution cache without changing the master DNA seed.
"dna_revision": ("INT", {
"default": 0,
"min": 0,
"max": 2147483647,
"step": 1,
}),
},
"optional": {
"dna_locks": ("STRING", {"default": "{}", "multiline": False}),
"dna_weights": ("STRING", {"default": "{}", "multiline": False}),
"dna_rerolls": ("STRING", {"default": "{}", "multiline": False}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = (
"positive_prompt",
"negative_prompt",
"Ollama_Generate_V2_Textbox_2",
"selection_summary",
)
FUNCTION = "generate_prompt"
CATEGORY = "RPG"
OUTPUT_NODE = True
def generate_prompt(
self,
race,
ethnicity,
gender,
age,
character_class,
hair_style,
hair_colour,
beard_style,
beard_colour,
clothes_style,
Augmentations,
emotion,
scene,
dna_seed=-1,
dna_revision=0,
dna_locks="{}",
dna_weights="{}",
dna_rerolls="{}",
):
if dna_seed is None or int(dna_seed) < 0:
dna_seed = secrets.randbits(32)
else:
dna_seed = int(dna_seed)
locks = _parse_state(dna_locks)
weights = _parse_state(dna_weights)
rerolls = _parse_state(dna_rerolls)
# Some ComfyUI frontend builds can treat auxiliary hidden widgets
# differently when building the execution payload. The DNA Locker
# therefore mirrors reroll/unlock state inside dna_locks as a fallback.
embedded_rerolls = locks.pop("__dna_rerolls", None)
if not rerolls and isinstance(embedded_rerolls, dict):
rerolls = embedded_rerolls
race_entry = RACE_DATA[race]
ethnicity_entry = ETHNICITY_DATA[ethnicity]
ethnicity_entry_for_prompt = dict(ethnicity_entry)
race_overrides = _race_overrides_ethnicity_visuals(race_entry)
if race_overrides and ethnicity_entry_for_prompt.get("prompt"):
ethnicity_entry_for_prompt["prompt"] = _remove_ethnicity_visual_conflicts(
ethnicity_entry_for_prompt["prompt"], race_overrides
)
selected_data = [
("Race", race, race_entry),
("Ethnicity", ethnicity, ethnicity_entry_for_prompt),
("Gender", gender, GENDER_DATA[gender]),
("Age", age, AGE_DATA[age]),
("Class", character_class, CLASS_DATA[character_class]),
("Hair Style", hair_style, HAIR_STYLE_DATA[hair_style]),
("Hair Colour", hair_colour, HAIR_COLOUR_DATA[hair_colour]),
("Beard Style", beard_style, BEARD_STYLE_DATA[beard_style]),
("Beard Colour", beard_colour, BEARD_COLOUR_DATA[beard_colour]),
("Clothes Style", clothes_style, CLOTHES_STYLE_DATA[clothes_style]),
("Augmentations", Augmentations, AUGMENT_DATA[Augmentations]),
("Emotion", emotion, EMOTION_DATA[emotion]),
("Scene", scene, SCENE_DATA[scene]),
]
resolved_prompts = []
resolved_negatives = []
all_selections = []
dna_groups = []
for field_name, selected_name, entry in selected_data:
if entry["prompt"]:
resolved, selections, dna = resolve_prompt_variants_with_trace(
entry["prompt"],
dna_seed,
f"{field_name}:positive",
locks,
weights,
rerolls,
)
resolved_prompts.append(resolved)
all_selections.extend(selections)
for group in dna:
group["field"] = field_name
group["source"] = "positive"
group["label"] = self._infer_variant_label(
entry["prompt"], group["options"], group["label"]
)
dna_groups.extend(dna)
if entry["negative_prompt"]:
resolved, selections, dna = resolve_prompt_variants_with_trace(
entry["negative_prompt"],
dna_seed,
f"{field_name}:negative",
locks,
weights,
rerolls,
)
resolved_negatives.append(resolved)
all_selections.extend(selections)
for group in dna:
group["field"] = field_name
group["source"] = "negative"
group["label"] = self._infer_variant_label(
entry["negative_prompt"], group["options"], group["label"]
)
dna_groups.extend(dna)
combined_prompt = "Create an extreme close-up portrait of " + ", ".join(resolved_prompts)
combined_negative_prompt = ", ".join(resolved_negatives)
if scene.lower().startswith("plain") or "chroma key" in scene.lower() or "solid" in scene.lower():
scene_description = (
f"Scene: Solid {scene.replace('Plain ', '').replace('Chroma key ', '')} "
"Background Only. No objects or distractions."
)
else:
scene_description = f"Scene: {scene}"
selection_summary = "\n".join([
f"Race: {race}",
f"Ethnicity: {ethnicity}",
f"Gender: {gender}",
f"Age: {age}",
f"Class: {character_class}",
f"Hair Style: {hair_style}",
f"Hair Colour: {hair_colour}",
f"Beard Style: {beard_style}",
f"Beard Colour: {beard_colour}",
f"Clothes Style: {clothes_style}",
f"Augmentations: {Augmentations}",
f"Emotion: {emotion}",
scene_description,
f"DNA Seed: {dna_seed}",
])
if race_overrides:
selection_summary += (
"\nRace Priority: " + ", ".join(sorted(race_overrides)) +
" override(s) applied to conflicting ethnicity traits."
)
ollama_description = (
"Create an extreme close-up portrait of " +
", ".join(resolved_prompts) +
", Include Art Style."
)
if all_selections:
selection_summary += "\nRandomized Choices Used: " + ", ".join(all_selections)
# Human-readable DNA record. This is also useful when the node is
# connected to logging/debugging tools.
if dna_groups:
selection_summary += "\nDNA Loci:"
for group in dna_groups:
if group["mode"] == "sculpted" and group["weights"]:
parts = [
f"{option}={weight:.3f}".rstrip("0").rstrip(".")
for option, weight in zip(group["options"], group["weights"])
if weight > 0.0005
]
resolved = " / ".join(parts)
else:
resolved = group["selected"]
selection_summary += (
f"\n- {group['field']} [{group['source']}] "
f"{group['label']}: {resolved} ({group['mode']})"
)
# ComfyUI flattens UI values by iterating each UI field. Therefore
# custom structured UI payloads must themselves be list-valued, or a
# dict here would arrive in JavaScript as ["seed", "groups"].
# Keep the DNA record as a single list item.
ui_payload = {
"dna": [{
"seed": dna_seed,
"groups": dna_groups,
}]
}
return {
"ui": ui_payload,
"result": (
combined_prompt,
combined_negative_prompt,
ollama_description,
selection_summary,
),
}
@staticmethod
def _infer_variant_label(source_text, options, fallback):
"""Best-effort label for common '... Skin tone' / '... eyes' groups."""
marker = "}"
matches = list(VARIANT_PATTERN.finditer(source_text))
for match in matches:
if [item.strip() for item in match.group(1).split("|")] == options:
suffix = source_text[match.end():]
suffix = suffix.lstrip(" ,)")
label_match = re.match(r"([A-Za-z][A-Za-z _-]{1,40})", suffix)
if label_match:
label = label_match.group(1).strip()
if label.lower().startswith(("skin tone", "eyes", "eye colour")):
return label
break
return fallback
NODE_CLASS_MAPPINGS = {
"RPGCharacterSelector": RPGCharacterSelector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RPGCharacterSelector": "RPG Character Selector"
}