9 Commits
Author SHA1 Message Date
lord-lethris c640b7e966 Release V2.5 2026-09-12 00:09:07 +01:00
lord-lethris 4cc0e8737e Bump version to 2.4.1 2026-09-10 18:26:06 +01:00
lord-lethris d74c38ecc1 Register DNA Locker web extension 2026-09-10 18:15:35 +01:00
lord-lethris b04a069841 Prepare V2.4 for Comfy Registry 2026-09-10 17:59:43 +01:00
Lord Lethris 981ff6bb63 Merge pull request #3 from lord-lethris/v2.4-dna-locker
V2.4 - DNA Locker
2026-09-10 17:35:05 +01:00
lord-lethris f482712a6c Fix Nodes Collection image filename 2026-09-10 17:29:49 +01:00
lord-lethris c32eaf19c7 V2.4 DNA Locker 2026-09-10 17:20:42 +01:00
lord-lethris 6517c951fa V2.4 DNA Locker 2026-09-10 17:19:30 +01:00
Lord Lethris 53dedad874 Bump version from 1.1.0 to 2.3.0 2026-09-02 23:05:04 +01:00
16 changed files with 5780 additions and 170 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 175 KiB

+77 -4
View File
@@ -1,10 +1,12 @@
# ComfyUI-RPG-Characters
**Current release: V2.5**
A custom node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that generates stylized prompts for RPG characters. This node outputs both **standard prompt formats** and enhanced **Ollama-style descriptive prompts** ideal for **extreme close-up portraits**.
✅ Compatible with **Stable Diffusion**, **SDXL**, **Flux**, and any model that uses structured prompts.
The generator supports both classic **AD&D / Fantasy** and **Sci-Fi / Cyberpunk** character creation, with expanded classes, clothing styles, scenes, and art styles. Sci-Fi characters can be created **without augmentations**, allowing fully augment-free characters as well as cybernetic ones.
The generator supports both classic **AD&D / Fantasy** and **Sci-Fi / Cyberpunk** character creation, with expanded classes, clothing styles, scenes, and art styles. V2.5 adds richer deterministic variation inside existing character choices and introduces a dedicated Pixel Art style. Sci-Fi characters can be created **without augmentations**, allowing fully augment-free characters as well as cybernetic ones.
---
@@ -15,7 +17,7 @@ The generator supports both classic **AD&D / Fantasy** and **Sci-Fi / Cyberpunk*
- Dark Fantasy
- Realistic
- Fantasy Illustration
- Digital Painting
- Pixel Art
- Sci-Fi / Cyberpunk
- 🧠 Generates:
@@ -31,6 +33,76 @@ The generator supports both classic **AD&D / Fantasy** and **Sci-Fi / Cyberpunk*
- Fantasy and Sci-Fi / Cyberpunk classes and clothing
- General / Neutral, Fantasy, and Sci-Fi / Cyberpunk scenes
- 🧬 **DNA Locker (V2.4)**
— Deterministic character DNA with per-locus re-rolls, Master Seed control, and a 2D DNA Sculptor for blending the nearest three variants. Preview changes live, apply or cancel sculpting, and reproduce character variations from the same seed.
- 🧬 **Richer Character Variants (V2.5)**
— Existing character choices now contain more deterministic internal variation, including richer beard and hair-style descriptions plus colour, trim, material, and detail variants inside existing clothing styles.
- 🖼️ **Expanded Art Styles (V2.5)**
— Refined Fantasy Illustration prompting for a more classic tabletop RPG illustration look and replaced the overlapping Digital Painting style with dedicated Pixel Art prompting.
---
## 🆕 V2.5 Data & Art Style Refinements
**V2.5** is a focused refinement release. It keeps the existing character-selection system and DNA Locker mechanics intact while making the underlying character data more visually varied and improving the art-style prompts.
### 🧬 Character Data Refinement
Existing user-facing choices have been enriched with deterministic internal prompt variants rather than adding large numbers of new dropdown options.
- 🧔 **Beard styles** — richer variation in length, texture, grooming, and shape while preserving the existing beard-style choices.
- 💇 **Hair styles** — restrained variation for generic styles, adding differences in length, texture, volume, and arrangement without changing distinctive named styles.
- 👘 **Clothing styles** — existing Fantasy, tavern/service, and Sci-Fi clothing choices now include appropriate internal colour, trim, material, and accent variations.
- 🧬 **Tiefling pigmentation** — expanded red-family skin-pigmentation variants and removed the human-skin fallback.
- 🎲 **Deterministic by design** — these internal `{A|B|C}` variants are resolved through the existing character DNA system, so the variation remains reproducible.
The goal is simple: **the dropdown describes the character choice; the DNA quietly determines the visual flavour.**
> ⚠️ **DNA is deterministic. Genetics are not.**
> Diffusion models can still interpret traits creatively, so deterministic prompt data does not guarantee an identical visual result across every model or checkpoint.
### 🎨 Art Style Refinement
V2.5 also refines the style prompts used by the **RPG Art Style Selector**:
- 🏰 **Fantasy Illustration** — now explicitly targets classic tabletop fantasy RPG artwork, hand-painted illustration, expressive linework, visible brushwork, stylised heroic forms, and pulp-fantasy aesthetics rather than photographic realism.
- 🕹️ **Pixel Art** — replaces the previous Digital Painting style with a genuinely different visual language: classic fantasy RPG pixel art, crisp hard-edged pixels, deliberate pixel clusters, limited palettes, dithering, sprite-art forms, and block-based shading.
- 🎨 **Digital Painting removed** — Fantasy Illustration already covered the painterly/illustrated territory, so the overlapping style was replaced with Pixel Art.
V2.5 deliberately does **not** introduce new DNA mechanics, segmentation, regional conditioning, or a custom sampler. Those remain potential future architectural work.
## 🆕 V2.4 Workflow Highlights — DNA Locker
The **AD&D Character Portrait Generator V2.4** introduces the **🧬 DNA Locker**, a deterministic character-variation system that lets you experiment with a character's visual traits without losing combinations you like.
### 🧬 DNA Locker
The DNA Locker gives each generated character a reproducible set of DNA loci — such as skin tone, eye colour, hair colour, and other supported visual traits.
- 🎲 **Re-roll individual traits** — keep rolling a specific locus until you find a combination you like.
- 🎲 **Re-roll the Master Seed** — generate a completely new DNA combination across all visible loci.
- 🧬 **DNA Sculptor** — blend the nearest three variants in 2D DNA space for finer control than simple random selection.
- 👁️ **Live feedback** — DNA changes are reflected immediately in the Locker while experimenting.
- ✓ **Apply / ✕ Cancel** — sculpt changes can be previewed live and either committed or discarded.
- 🔢 **Reproducible** — the Master Seed allows the same DNA combination to be recreated.
The DNA Locker is designed to sit alongside the normal character-selection workflow: roll the dice, find a combination you like, sculpt it if you want more control, then run the workflow.
> ⚠️ **DNA is deterministic. Genetics are not.**
> Diffusion models can occasionally interpret traits creatively, so the final image won't always perfectly match the DNA Locker. **DNA is fragile. Handle with care.** 🧬😏
**Your character. Your DNA. Your dice.**
### 🖼️ V2.4 Example Workflow
The repository includes a complete **AD&D Character Portrait Generator V2.4 - Basic** example showing the DNA Locker in use.
**DNA Locker Highlight:**
![AD&D Character Portrait Generator V2.4 - DNA Locker](Examples/AD&D_Character_Portrait_Generator_V2_4_Basic.png)
**Workflow File:**
[`AD&D Character Portrait Generator V2.4 - Basic.json`](Examples/AD%26D%20Character%20Portrait%20Generator%20V2.4%20-%20Basic.json)
---
## 🆕 V2.3 Workflow Highlights
@@ -144,7 +216,8 @@ Restart ComfyUI after installation.
1. Add the **RPG Art Style Selector** and RPG character nodes from the "RPG" category.
2. Select the character traits, clothing style, scene, and art style you want.
3. Mix Fantasy, Sci-Fi / Cyberpunk, and General / Neutral options as required.
4. Connect outputs as needed:
4. If using V2.4 or later, use the **🧬 DNA Locker** to re-roll individual traits or the Master Seed, then optionally sculpt the DNA for finer control.
5. Connect outputs as needed:
- `positive_prompt` / `negative_prompt` → your SD/SDXL/Flux prompt node
- `Ollama_Positive_Textbox_1` / `Ollama_Negative_Textbox_1` → LLM or prompt analysis/feedback tools
@@ -169,7 +242,7 @@ Credit for the [ComfyUI-Ollama node](https://github.com/stavsap/comfyui-ollama)
## 🔮 Future Plans
- Dynamic LLM integration for auto-generating character prompts
- Additional art styles (e.g., pixel art, steampunk, noir)
- Additional art styles (e.g., steampunk, noir, and other distinct visual styles)
- Further expansion of character classes, clothing styles, and environments
- Additional workflow improvements and generation presets
+73
View File
@@ -0,0 +1,73 @@
{
"version": "V2.4 DNA Locker v17 \u2014 live sculpt preview with Apply/Cancel",
"changed_files": [
"rpg_characters_node.py",
"web/rpg_dna_locker.js",
"README.md",
"V2.4_DNA_Locker_MANIFEST.json"
],
"tested": [
"Python compilation",
"JavaScript syntax (node --check)",
"Orc: zero DNA loci",
"Half-Orc: zero DNA loci",
"Tiefling: one DNA locus",
"Aasimar: two independent DNA loci",
"deterministic same-seed selection",
"weighted/sculpted selection",
"Aasimar race priority and eye-safe prompt",
"nearest-three weight limiting",
"2D sculptor JavaScript syntax",
"ComfyUI structured UI list-payload compatibility",
"DNA UI payload flattening regression test",
"per-locus reroll state without master-seed changes",
"locus dice queues a reroll without changing other loci",
"LiteGraph graph-change notification after hidden DNA state edits",
"deliberate rerolls avoid the immediately previous variant when alternatives exist",
"immediate in-memory DNA selection preview after locus reroll",
"pending DNA previews clear after authoritative backend execution",
"hidden DNA execution revision invalidates ComfyUI cache without changing master seed",
"materialised -1 master seed is returned to and persisted in the hidden dna_seed widget",
"Re-roll-only DNA controls (no lock/unlock UI)",
"individual Re-roll immediately previews a new variant",
"master-seed Re-roll immediately previews all DNA loci",
"master-seed preview state persists into execution",
"stale legacy lock entries ignored by the V2.4 backend",
"sculpted loci remain directly editable via DNA Sculptor",
"live sculpt preview mirrors the active DNA locus in the main Locker before Run",
"sculpt preview is isolated from committed DNA until Apply",
"sculpt Cancel discards the preview without changing committed DNA",
"sculpt Apply commits the previewed nearest-three weights",
"removed Return to Random because the main Re-roll control provides randomisation",
"sculptor close X now explicitly cancels the current sculpt edit"
],
"changes": [
"locus dice immediately displays the newly selected variant",
"reroll preview is persisted so Run uses exactly the variant shown",
"other DNA loci remain untouched by per-locus rerolls",
"persist backend-materialised master seed into dna_seed after first execution",
"stop displaying a fake random master seed when dna_seed is unset",
"master-seed dice now writes the new seed and resets DNA controls immediately",
"removed Lock/Unlock controls from the DNA Locker UI",
"replaced per-locus controls with Re-roll and Sculpt",
"master-seed dice now immediately refreshes every visible DNA variant",
"master-seed changes persist the preview choices so Run uses exactly what is shown",
"legacy lock transport remains only for backwards compatibility and no longer constrains V2.4 execution",
"2D sculptor now has explicit Cancel (\u2715) and Apply (\u2713) actions",
"main Locker cards update live while sculpting",
"removed the redundant Return to Random action",
"uncommitted sculpt previews are discarded on Cancel or drawer close"
],
"tests": [
"reroll preview is visible before execution",
"backend honours persisted reroll preview",
"backend honours embedded reroll fallback when auxiliary reroll input is absent",
"backend DNA seed is propagated back to the frontend and reused on subsequent queues",
"master-seed Re-roll updates visible Skin tone and Eyes before Run",
"per-locus Re-roll updates only that locus before Run",
"Run consumes the exact preview shown in the Locker",
"sculpt drag updates the main Locker before execution",
"Cancel leaves committed DNA weights unchanged",
"Apply commits exactly the previewed weights"
]
}
+1
View File
@@ -6,6 +6,7 @@ from .rpg_prompt_concatenator_node import NODE_DISPLAY_NAME_MAPPINGS as CONCATEN
from .prompt_conditioning_converter_node import NODE_CLASS_MAPPINGS as CONDITIONING_NODE_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CONDITIONING_NODE_DISPLAY
from .model_likeness_switch import ModelLikenessSwitch
WEB_DIRECTORY = "./web"
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(CHARACTER_MAPPINGS)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+4 -24
View File
@@ -1,36 +1,16 @@
[project]
name = "rpg-characters"
description = "Stylized RPG character prompt generator for ComfyUI. Supports standard and Ollama-based prompts, works with SD, SDXL, Flux, and more."
version = "1.1.0"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
# "Operating System :: OS Independent",
#
# # OR for OS-specific nodes, specify the supported systems:
# "Operating System :: Microsoft :: Windows", # Windows specific
# "Operating System :: POSIX :: Linux", # Linux specific
# "Operating System :: MacOS", # macOS specific
#
# # GPU Accelerator support. Pick the ones that are supported by your extension.
# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
# "Environment :: GPU :: Intel Arc", # Intel Arc support
# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
# "Environment :: GPU :: Apple Metal", # Apple Metal support
# ]
version = "2.5"
license = {file = "LICENSE"}
[project.urls]
Repository = "https://github.com/lord-lethris/ComfyUI-RPG-Characters"
# Used by Comfy Registry https://registry.comfy.org
Documentation = "https://github.com/lord-lethris/ComfyUI-RPG-Characters/wiki"
"Bug Tracker" = "https://github.com/lord-lethris/ComfyUI-RPG-Characters/issues"
[tool.comfy]
PublisherId = "lord-lethris"
DisplayName = "ComfyUI-RPG-Characters"
Icon = ""
includes = []
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
Icon = "https://raw.githubusercontent.com/lord-lethris/ComfyUI-RPG-Characters/main/icon.png"
includes = []
+12 -11
View File
@@ -10,7 +10,7 @@ class RPGArtStyleSelector:
"Dark Fantasy",
"Realistic",
"Fantasy Illustration",
"Digital Painting",
"Pixel Art",
"Sci-Fi / Cyberpunk", # NEW
],)
}
@@ -44,9 +44,9 @@ class RPGArtStyleSelector:
"positive": "fantasy art, painterly strokes, epic atmosphere, vibrant colors",
"negative": "dull, flat, low contrast"
},
"Digital Painting": {
"positive": "digital painting, smooth brushwork, rich textures, detailed lighting",
"negative": "pixelated, noisy, flat lighting"
"Pixel Art": {
"positive": "pixel art, classic fantasy RPG pixel art, hand-crafted pixel illustration, crisp hard-edged pixels, limited color palette, deliberate pixel clusters, dithering, sprite-art aesthetics, stylized fantasy character portrait",
"negative": "photorealistic, photographic, hyperrealistic, 3D render, CGI, smooth gradients, airbrushed shading, anti-aliased edges, painterly brushstrokes, realistic skin pores, glossy realism, blurry, noisy"
},
"Sci-Fi / Cyberpunk": { # NEW
"positive": "sci-fi, cyberpunk, neon lights, futuristic tech, detailed cybernetic elements, vibrant neon colors",
@@ -93,14 +93,15 @@ class RPGArtStyleSelector:
"**Upper Chest/Clothing**: Armor or robes, enchanted detail, magical symbols.\n\n"
"**Soft Background**: Clouds, magical auras, glowing cliffs or ruins."
),
"Digital Painting": (
"Use digital painting style: rich texture, brushstrokes, dynamic lighting.\n\n"
"Pixel Art": (
"Use classic fantasy RPG pixel art style: hand-crafted pixel illustration with crisp hard-edged pixels, deliberate pixel clusters, limited but expressive color palettes, strong silhouettes, dithering, selective highlights, and readable sprite-art forms. The result should look like a deliberately created piece of pixel art, not a normal illustration that has merely been pixelated.\n\n"
"**1024x1024 Extreme Close-Up Portrait**\n\n"
"Painted detail with soft blend transitions and visible brush techniques.\n\n"
"**Eyes**: Reflective, highlighted with strokes.\n\n"
"**Hair**: Painterly motion with rich shading.\n\n"
"**Upper Chest/Clothing**: Stylized materials with visual brushwork.\n\n"
"**Soft Background**: Smudged gradients and digital bloom."
"Render the character as a polished fantasy RPG pixel-art portrait, inspired by classic 16-bit and 32-bit game artwork. Use visible pixel structure throughout the image, stepped edges, block-based shading, clustered pixels, controlled dithering, and deliberate colour grouping. Keep important facial and costume features readable while simplifying tiny details into intentional pixel shapes.\n\n"
"**Eyes**: Bright, expressive pixel-art eyes with a few carefully placed highlight pixels and strong readable shapes.\n\n"
"**Hair**: Build hair from grouped pixel clusters and sweeping stepped shapes, with a small number of highlight bands rather than individual realistic strands.\n\n"
"**Upper Chest/Clothing**: Use strong silhouettes, blocky folds, clustered highlights and shadows, and simplified decorative details that remain clearly readable at pixel scale.\n\n"
"**Soft Background**: Construct the environment from layered pixel-art shapes, atmospheric colour blocks, silhouettes, dithering and simplified environmental details rather than photographic depth of field.\n\n"
"Avoid photorealistic rendering, smooth photographic gradients, anti-aliased edges, painterly brushwork, CGI/3D-render appearance, excessive micro-detail, and simply applying a pixelation filter to realistic artwork."
),
"Sci-Fi / Cyberpunk": ( # NEW
"Use sci-fi / cyberpunk style: neon lights, futuristic city, cybernetic implants, high-tech fashion.\n\n"
+9 -9
View File
@@ -7,23 +7,23 @@ AUGMENT_DATA = {
# AD&D / Fantasy Augments
# =========================
"Magic Tattoo": {
"prompt": "(Glowing Arcane Tattoo on Arm, Mystical Runes, Fantasy Magic Enhancement)",
"prompt": "({Glowing arcane tattoo|Mystical rune tattoo|Enchanted sigil} on the {arm|shoulder|neck}, {blue|violet|cyan|gold} magical glow, fantasy magic enhancement)",
"negative_prompt": ""
},
"Enchanted Eye": {
"prompt": "(Mystical Eye Implant, Glowing Iris, Magical Enhancement, Fantasy Style)",
"prompt": "({Mystical|Enchanted|Arcane} eye enhancement, {gold|blue|violet|green} glowing iris, magical fantasy enhancement)",
"negative_prompt": ""
},
"Runed Armor": {
"prompt": "(Armor Inscribed with Glowing Runes, Magical Protection, Fantasy Style)",
"prompt": "(Armor inscribed with {glowing|faintly glowing|luminous} {arcane|holy|protective} runes, magical protection, fantasy style)",
"negative_prompt": ""
},
"Winged Boots": {
"prompt": "(Leather Boots with Magical Wings, Floating Effect, Fantasy Enchantment)",
"prompt": "(Leather boots with {small feathered|ornate magical|ethereal} wings, {subtle|visible} floating effect, fantasy enchantment)",
"negative_prompt": ""
},
"Arcane Gauntlet": {
"prompt": "(Glowing Gauntlet with Arcane Symbols, Fantasy Magic Enhancement)",
"prompt": "({Glowing|Luminous|Rune-inscribed} gauntlet with {arcane symbols|mystical runes|magical glyphs}, {blue|violet|gold|cyan} magical glow, fantasy enhancement)",
"negative_prompt": ""
},
@@ -31,7 +31,7 @@ AUGMENT_DATA = {
# Sci-Fi Augments
# =========================
"Cybernetic Eye": {
"prompt": "(Futuristic Cybernetic Eye Implant, Glowing Red or Blue, Augmented Vision, Cyberpunk Style)",
"prompt": "(Futuristic cybernetic eye implant, glowing {red|blue|cyan|amber} iris, augmented vision, cyberpunk style)",
"negative_prompt": ""
},
"Neural Uplink": {
@@ -47,7 +47,7 @@ AUGMENT_DATA = {
"negative_prompt": ""
},
"Holographic Tattoo": {
"prompt": "(Holographic Tattoo on Skin, Neon Glowing Lines, Futuristic Cyberpunk Aesthetic)",
"prompt": "(Holographic tattoo on {arm|shoulder|neck}, {cyan|magenta|blue|violet} neon glowing lines, futuristic cyberpunk aesthetic)",
"negative_prompt": ""
},
"Subdermal Armor": {
@@ -59,11 +59,11 @@ AUGMENT_DATA = {
"negative_prompt": ""
},
"Optical HUD": {
"prompt": "(Augmented Reality HUD in Eye Implant, Cyberpunk Style, Neon Interface)",
"prompt": "(Augmented reality HUD in eye implant, {cyan|green|blue|amber} holographic interface, cyberpunk style)",
"negative_prompt": ""
},
"Voice Modulator": {
"prompt": "(Cyberpunk Voice Modulator Implant, Futuristic Tech, Holographic Effects)",
"prompt": "(Cyberpunk voice modulator implant with {cyan|blue|violet|red} holographic effects, futuristic tech)",
"negative_prompt": ""
},
"Nano Injector": {
+37 -37
View File
@@ -3,47 +3,47 @@ BEARD_STYLE_DATA = {
# AD&D / Base Beard
# =========================
"No Beard": {"prompt": "No Beard", "negative_prompt": "Beard"},
"Anchor": {"prompt": "Medium Anchor style Wavy beard", "negative_prompt": ""},
"Balbo": {"prompt": "Short Balbo style Straight beard", "negative_prompt": ""},
"Bandholz": {"prompt": "Long Bandholz style Scruffy beard", "negative_prompt": ""},
"Chin strap": {"prompt": "Chin strap style beard", "negative_prompt": ""},
"Circle Beard": {"prompt": "Circle Beard style beard", "negative_prompt": ""},
"Ducktail": {"prompt": "Long Ducktail style Straight beard", "negative_prompt": ""},
"Extended Goatee": {"prompt": "Extended Goatee style beard", "negative_prompt": ""},
"Anchor": {"prompt": "{short|medium|long} Anchor style {wavy|straight|slightly curled} beard", "negative_prompt": ""},
"Balbo": {"prompt": "{short|medium} Balbo style {straight|slightly wavy} beard, {neatly trimmed|sharply groomed|slightly rugged}", "negative_prompt": ""},
"Bandholz": {"prompt": "{long|very long} Bandholz style {scruffy|wavy|coarse} beard, {full and natural|slightly unruly|well groomed}", "negative_prompt": ""},
"Chin strap": {"prompt": "{thin|medium|defined} chin strap beard, {cleanly shaped|neatly trimmed|subtle}", "negative_prompt": ""},
"Circle Beard": {"prompt": "Circle Beard style {short|medium} beard, {neatly trimmed|cleanly groomed|slightly rugged}", "negative_prompt": ""},
"Ducktail": {"prompt": "{medium|long} Ducktail style {straight|slightly wavy} beard, {neatly shaped|tapered and groomed|full at the chin}", "negative_prompt": ""},
"Extended Goatee": {"prompt": "{short|medium|pointed} Extended Goatee style beard, {neatly trimmed|sharply groomed|slightly rugged}", "negative_prompt": ""},
"French Fork": {"prompt": "{Long|Short} French Fork style {Wavy|Straight|Curvy} beard", "negative_prompt": ""},
"Full Beard": {"prompt": "Full Beard", "negative_prompt": ""},
"Full Polished": {"prompt": " Full Polished style beard", "negative_prompt": ""},
"Garibaldi": {"prompt": "Long Garibaldi style Wavy beard", "negative_prompt": ""},
"Goatee": {"prompt": "Goatee style beard", "negative_prompt": ""},
"Handlebar Mustache": {"prompt": "Handlebar Mustache style beard", "negative_prompt": ""},
"Hipster": {"prompt": "Short Hipster style Scruffy beard", "negative_prompt": ""},
"Hollywoodian": {"prompt": "Short Hollywoodian style Boxed beard", "negative_prompt": ""},
"Imperial": {"prompt": "Imperial style beard", "negative_prompt": ""},
"Kalamazoo": {"prompt": "long Kalamazoo style Coarse beard", "negative_prompt": ""},
"Klingon": {"prompt": "short Klingon style pointed beard", "negative_prompt": ""},
"Long Beard": {"prompt": "Long style beard", "negative_prompt": ""},
"Lumberjack": {"prompt": "Long Lumberjack style Coarse beard", "negative_prompt": ""},
"Mutton Chops": {"prompt": "Short Mutton Chops style Patchy beard", "negative_prompt": ""},
"Napoleon": {"prompt": "Short Napoleon style boxed beard", "negative_prompt": ""},
"Old Wizard": {"prompt": "Old Wizard style beard", "negative_prompt": ""},
"Pointed": {"prompt": "Medium Pointed style pointed beard", "negative_prompt": ""},
"Power": {"prompt": "Power style beard", "negative_prompt": ""},
"Royale": {"prompt": "Medium Royale style Wavy beard", "negative_prompt": ""},
"Russian": {"prompt": "long Russian style straight beard", "negative_prompt": ""},
"Scruffy": {"prompt": "Scruffy style beard", "negative_prompt": ""},
"Shadow": {"prompt": "short Shadow style Scruffy beard", "negative_prompt": ""},
"Full Beard": {"prompt": "{short|medium|long|very long} full beard, {neatly groomed|naturally rugged|thick and well kept}, with {trimmed|slightly unruly|full} edges", "negative_prompt": ""},
"Full Polished": {"prompt": "{short|medium|long} Full Polished style beard, {smoothly groomed|carefully trimmed|precisely shaped}", "negative_prompt": ""},
"Garibaldi": {"prompt": "{medium|long|very long} Garibaldi style {wavy|slightly curly|natural} beard, {full and rounded|thick and natural|slightly unruly}", "negative_prompt": ""},
"Goatee": {"prompt": "{short|medium|pointed} Goatee style beard, {neatly trimmed|sharply groomed|slightly rugged}", "negative_prompt": ""},
"Handlebar Mustache": {"prompt": "{short|medium|full} Handlebar Mustache style facial hair, {neatly groomed|dramatically curled|slightly rugged}", "negative_prompt": ""},
"Hipster": {"prompt": "{short|medium|long} Hipster style {scruffy|coarse|wavy} beard, {slightly unruly|full and natural|carefully groomed}", "negative_prompt": ""},
"Hollywoodian": {"prompt": "{short|medium} Hollywoodian style boxed beard, {neatly trimmed|cleanly shaped|slightly rugged}", "negative_prompt": ""},
"Imperial": {"prompt": "{elegant|sharply groomed|slightly curled} Imperial style facial hair", "negative_prompt": ""},
"Kalamazoo": {"prompt": "{long|very long} Kalamazoo style {coarse|wavy|straight} beard, {full and rugged|naturally thick|slightly unruly}", "negative_prompt": ""},
"Klingon": {"prompt": "{short|medium} Klingon style pointed beard, {sharp and groomed|rugged|slightly wild}", "negative_prompt": ""},
"Long Beard": {"prompt": "{long|very long|flowing} beard, {thick|full|slightly wavy}, {carefully groomed|naturally rugged|slightly unruly}", "negative_prompt": ""},
"Lumberjack": {"prompt": "{long|very long} Lumberjack style {coarse|thick|wavy} beard, {full and rugged|naturally heavy|slightly unruly}", "negative_prompt": ""},
"Mutton Chops": {"prompt": "{short|medium|long} Mutton Chops style {thick|full|slightly rugged} side facial hair", "negative_prompt": ""},
"Napoleon": {"prompt": "{short|medium} Napoleon style boxed beard, {neatly groomed|sharply trimmed|slightly rugged}", "negative_prompt": ""},
"Old Wizard": {"prompt": "{long|very long|flowing} Old Wizard style beard, {white|grey|silver} and {wavy|straight|slightly tangled}", "negative_prompt": ""},
"Pointed": {"prompt": "{short|medium|long} Pointed style pointed beard, {neatly groomed|sharply tapered|slightly rugged}", "negative_prompt": ""},
"Power": {"prompt": "{short|medium|full} Power style beard, {neatly trimmed|thick and natural|carefully groomed}", "negative_prompt": ""},
"Royale": {"prompt": "{short|medium|long} Royale style {wavy|straight|slightly curled} beard, {neatly groomed|full and natural|slightly rugged}", "negative_prompt": ""},
"Russian": {"prompt": "{long|very long} Russian style {straight|wavy|coarse} beard, {full and heavy|naturally rugged|carefully groomed}", "negative_prompt": ""},
"Scruffy": {"prompt": "{short|medium|long} scruffy beard, {messy|coarse|wavy}, naturally unkempt", "negative_prompt": ""},
"Shadow": {"prompt": "{very short|short|medium} Shadow style stubble beard, {scruffy|neatly groomed|natural}", "negative_prompt": ""},
"Shaggy": {"prompt": "{Medium|Long} Shaggy style Scruffy beard", "negative_prompt": ""},
"Shenandoah": {"prompt": "Medium Shenandoah style wavy beard", "negative_prompt": ""},
"Sideburns": {"prompt": "Sideburns style beard", "negative_prompt": ""},
"Soul Patch": {"prompt": "Soul Patch style beard", "negative_prompt": ""},
"Stubble": {"prompt": "Stubble style beard", "negative_prompt": ""},
"Shenandoah": {"prompt": "{medium|long} Shenandoah style {wavy|straight|slightly curled} beard, {full and rounded|naturally thick|carefully groomed}", "negative_prompt": ""},
"Sideburns": {"prompt": "{short|medium|long} sideburns, {neatly trimmed|full|slightly rugged}", "negative_prompt": ""},
"Soul Patch": {"prompt": "{small|defined|subtle} soul patch beneath the lower lip", "negative_prompt": ""},
"Stubble": {"prompt": "{light|medium|heavy} beard stubble, {evenly distributed|natural|slightly rugged}", "negative_prompt": ""},
"Tapered": {"prompt": "{Short|Medium} Tapered style {Straight|Wavy} beard", "negative_prompt": ""},
"Thin": {"prompt": "short Thin style fine beard", "negative_prompt": ""},
"Van Dyke": {"prompt": "Van Dyke style beard", "negative_prompt": ""},
"Verdi": {"prompt": "Verdi style beard", "negative_prompt": ""},
"Viking Long": {"prompt": "Viking Long style beard", "negative_prompt": ""},
"Yeard": {"prompt": "Long Yeard style Straight beard", "negative_prompt": ""},
"Zappa": {"prompt": "Zappa style beard", "negative_prompt": ""},
"Van Dyke": {"prompt": "{short|medium|long} Van Dyke beard, {sharply pointed|neatly groomed|slightly rugged}", "negative_prompt": ""},
"Verdi": {"prompt": "{medium|long} Verdi style beard, {full and rounded|carefully groomed|slightly wavy}", "negative_prompt": ""},
"Viking Long": {"prompt": "{long|very long|braided} Viking Long style beard, {thick and rugged|wavy and full|naturally heavy}", "negative_prompt": ""},
"Yeard": {"prompt": "{long|very long} Yeard style {straight|wavy|coarse} beard, {full and natural|slightly unruly|carefully groomed}", "negative_prompt": ""},
"Zappa": {"prompt": "{short|medium} Zappa style facial hair, {neatly groomed|slightly rugged|sharply shaped}", "negative_prompt": ""},
# =========================
# Sci-Fi / Cyberpunk Beards
+37 -37
View File
@@ -10,122 +10,122 @@ CLOTHES_STYLE_DATA = {
# =========================
"AD&D - Adventurer’s Gear": {
"prompt": "wearing fantasy adventurer’s gear, practical travel clothing",
"prompt": "wearing {brown|dark brown|forest green|olive|charcoal|tan|navy blue} practical fantasy adventurer’s gear, layered travel clothing, {brown leather|dark leather|worn canvas|rough wool} straps and utility details",
"negative_prompt": "futuristic materials, cyberpunk fashion"
},
"AD&D - Assassin’s Garb": {
"prompt": "wearing fantasy assassin’s garb, dark stealth clothing",
"prompt": "wearing {black|charcoal|dark grey|midnight blue|dark green|deep burgundy} fantasy assassin’s garb, layered stealth clothing, {black|dark grey|dark steel|deep burgundy} fitted trim and concealed fastenings",
"negative_prompt": "bright colors, heavy armor, cyberpunk techwear"
},
"AD&D - Barbarian Furs": {
"prompt": "wearing barbarian furs, primal and rugged clothing",
"prompt": "wearing {brown|dark brown|black|white|grey|deep red} barbarian furs, primal rugged clothing, {bronze|iron|silver|bone|dark steel} adornments and leather bindings",
"negative_prompt": "refined fabrics, cyberpunk materials"
},
"AD&D - Bardic Costume": {
"prompt": "wearing bardic costume, colorful performer attire",
"prompt": "wearing {crimson|royal blue|emerald green|deep purple|black|ivory|gold} bardic costume, colorful performer attire, {gold|silver|bronze|black|white} embroidered trim and decorative sash",
"negative_prompt": "heavy armor, cyberpunk fashion"
},
"AD&D - Cleric Vestments": {
"prompt": "wearing cleric vestments, religious fantasy robes",
"prompt": "wearing {white|ivory|crimson|royal blue|deep purple|emerald green|black} cleric vestments, layered religious fantasy robes, {gold|silver|bronze|white|crimson} embroidered trim and sash",
"negative_prompt": "dark occult clothing, cyberpunk tech"
},
"AD&D - Courtier’s Dress": {
"prompt": "wearing courtier’s dress, elegant noble fashion",
"prompt": "wearing {crimson|royal blue|emerald green|deep purple|black|ivory|burgundy} elegant courtier’s dress, rich noble fashion, {gold|silver|bronze|black|white} embroidered trim and sash",
"negative_prompt": "battle armor, cyberpunk attire"
},
"AD&D - Drow Attire": {
"prompt": "wearing drow attire, dark elven clothing, underdark fashion",
"prompt": "wearing {black|deep purple|midnight blue|charcoal|deep burgundy} drow attire, dark elven underdark fashion, {silver|black|dark steel|deep purple} intricate trim and layered fabric",
"negative_prompt": "bright noble clothing, cyberpunk materials"
},
"AD&D - Druidic Garments": {
"prompt": "wearing druidic garments, natural fibers, nature-themed clothing",
"prompt": "wearing {forest green|moss green|olive|brown|tan|dark grey} druidic garments, natural fiber clothing, {brown leather|woven hemp|wooden|bronze} nature-themed trim and bindings",
"negative_prompt": "metal armor, cyberpunk clothing"
},
"AD&D - Hermit’s Garb": {
"prompt": "wearing hermit’s garb, worn and simple clothing",
"prompt": "wearing {brown|dark brown|grey|charcoal|olive|faded blue} worn simple hermit’s garb, rough layered clothing, {brown leather|rough wool|weathered cloth} ties and patched details",
"negative_prompt": "luxury fabrics, cyberpunk fashion"
},
"AD&D - Lower Class Garb": {
"prompt": "wearing lower class medieval clothing, simple worn garments",
"prompt": "wearing {brown|grey|charcoal|olive|faded blue|muted burgundy} simple worn lower class medieval clothing, rough layered garments, {brown|dark grey|tan} patched fabric and plain bindings",
"negative_prompt": "luxury fashion, cyberpunk clothing"
},
"AD&D - Mage Robes": {
"prompt": "wearing mage robes, flowing arcane garments",
"prompt": "wearing {gold|silver|bronze|red|purple|blue|green|yellow|black|white} flowing fantasy mage robes, layered arcane garments, {gold|silver|bronze|red|purple|blue|green|white|black} embroidered fabric and sash",
"negative_prompt": "armor plating, cyberpunk techwear"
},
"AD&D - Merchant’s Clothing": {
"prompt": "wearing merchant’s clothing, practical fantasy attire",
"prompt": "wearing {brown|burgundy|navy blue|forest green|charcoal|tan|cream} practical merchant’s clothing, layered fantasy attire, {gold|silver|bronze|dark brown} trim, belt and fabric accents",
"negative_prompt": "combat armor, cyberpunk fashion"
},
"AD&D - Middle Class Garb": {
"prompt": "wearing middle class medieval clothing, modest but tidy attire",
"prompt": "wearing {brown|burgundy|navy blue|forest green|mustard|charcoal|cream} modest tidy middle class medieval clothing, layered garments, {brown|bronze|silver|cream|dark grey} trim and simple sash",
"negative_prompt": "royal clothing, cyberpunk fashion"
},
"AD&D - Monk Robes": {
"prompt": "wearing monk robes, simple disciplined garments",
"prompt": "wearing {brown|dark brown|grey|charcoal|cream|deep red|saffron} simple disciplined monk robes, layered garments, {dark brown|black|cream|red|saffron} cloth belt and restrained trim",
"negative_prompt": "ornate armor, cyberpunk clothing"
},
"AD&D - Noble Attire": {
"prompt": "wearing noble attire, rich fabrics, aristocratic fashion",
"prompt": "wearing {crimson|royal blue|emerald green|deep purple|black|ivory|burgundy} noble attire, rich fabrics, aristocratic fashion, {gold|silver|bronze|black|white} embroidered trim and sash",
"negative_prompt": "rags, cyberpunk clothing"
},
"AD&D - Paladin Plate": {
"prompt": "wearing paladin plate armor, holy knight armor",
"prompt": "wearing {silver|steel|gold|bronze|white} paladin plate armor, holy knight armor, {blue|crimson|gold|white|black} heraldic accents and engraved trim",
"negative_prompt": "dark armor, cyberpunk materials"
},
"AD&D - Pirate’s Clothing": {
"prompt": "wearing pirate’s clothing, rugged seafaring attire",
"prompt": "wearing {black|charcoal|navy blue|burgundy|brown|cream|dark green} rugged pirate’s clothing, layered seafaring attire, {brown leather|black leather|brass|silver} belts, buckles and trim",
"negative_prompt": "formal noble wear, cyberpunk fashion"
},
"AD&D - Rags": {
"prompt": "wearing tattered rags, extremely poor clothing",
"prompt": "wearing {brown|grey|charcoal|faded blue|olive|dirty cream} tattered rags, extremely poor layered clothing, mismatched {brown|grey|faded blue|olive} patches and frayed fabric",
"negative_prompt": "luxury clothing, cyberpunk fashion"
},
"AD&D - Ranger Outfit": {
"prompt": "wearing ranger outfit, practical wilderness gear",
"prompt": "wearing {forest green|olive|brown|dark grey|tan|deep green} practical ranger outfit, layered wilderness gear, {brown leather|dark green|olive|charcoal|tan} straps, pouches and trim",
"negative_prompt": "urban cyberpunk fashion"
},
"AD&D - Rogue’s Outfit": {
"prompt": "wearing rogue’s outfit, agile stealth clothing",
"prompt": "wearing {black|charcoal|dark grey|midnight blue|dark green|deep burgundy} agile rogue’s outfit, layered stealth clothing, {black leather|dark brown leather|dark grey|deep burgundy} fitted trim, straps and utility details",
"negative_prompt": "heavy armor, cyberpunk techwear"
},
"AD&D - Sailor’s Gear": {
"prompt": "wearing sailor’s gear, nautical fantasy clothing",
"prompt": "wearing {navy blue|cream|white|charcoal|brown|faded blue} practical sailor’s gear, layered nautical fantasy clothing, {navy blue|brown leather|cream|red|dark grey} rope, trim and sash details",
"negative_prompt": "desert gear, cyberpunk fashion"
},
"AD&D - Scholar’s Robes": {
"prompt": "wearing scholar’s robes, academic fantasy attire",
"prompt": "wearing {navy blue|burgundy|deep purple|brown|charcoal|cream|forest green} scholar’s robes, layered academic fantasy attire, {gold|silver|bronze|cream|black} embroidered trim and sash",
"negative_prompt": "battle armor, cyberpunk clothing"
},
"AD&D - Upper Class Garb": {
"prompt": "wearing upper class medieval clothing, refined fashion",
"prompt": "wearing {burgundy|royal blue|emerald green|deep purple|black|ivory|navy blue} refined upper class medieval clothing, rich layered garments, {gold|silver|bronze|black|white} embroidered trim and sash",
"negative_prompt": "rags, cyberpunk attire"
},
"AD&D - Warrior’s Armor": {
"prompt": "wearing warrior’s armor, battle-ready fantasy armor",
"prompt": "wearing {steel|iron|blackened steel|bronze|silver} battle-ready fantasy warrior’s armor, layered protective gear, {red|blue|black|white|deep green} heraldic cloth and {steel|bronze|silver|iron} metal trim",
"negative_prompt": "futuristic materials, cyberpunk armor"
},
@@ -134,22 +134,22 @@ CLOTHES_STYLE_DATA = {
# =========================
"AD&D - Innkeeper": {
"prompt": "wearing traditional fantasy innkeeper’s clothing, practical rustic attire, waistcoat, apron",
"prompt": "wearing {brown|dark brown|forest green|burgundy|cream|charcoal} traditional fantasy innkeeper’s clothing, practical rustic attire, {brown|dark green|burgundy|navy blue} waistcoat and apron, {brass|bronze|silver} buttons",
"negative_prompt": "modern clothing, futuristic clothing, cyberpunk fashion"
},
"AD&D - Barman": {
"prompt": "wearing traditional fantasy tavern barman’s clothing, rustic shirt, waistcoat, apron",
"prompt": "wearing {brown|cream|white|dark green|burgundy|navy blue} traditional fantasy tavern barman’s clothing, rustic shirt, {brown|charcoal|burgundy|forest green} waistcoat and apron, {brass|bronze|silver} buttons",
"negative_prompt": "modern bartender, futuristic clothing, cyberpunk fashion"
},
"AD&D - Tavern Wench": {
"prompt": "wearing traditional fantasy tavern wench attire, rustic tavern dress, apron",
"prompt": "wearing {burgundy|forest green|deep blue|brown|cream|plum} traditional fantasy tavern wench attire, rustic tavern dress, {cream|white|brown|burgundy} apron and {brass|bronze|silver} trim",
"negative_prompt": "modern clothing, futuristic clothing, cyberpunk fashion"
},
"AD&D - Tavern Server": {
"prompt": "wearing traditional fantasy tavern server clothing, rustic service attire, simple shirt, waistcoat, apron",
"prompt": "wearing {brown|cream|white|forest green|burgundy|navy blue} traditional fantasy tavern server clothing, rustic service attire, {cream|white|brown|dark green} simple shirt, waistcoat and apron, {brass|bronze|silver} buttons",
"negative_prompt": "modern clothing, futuristic clothing, cyberpunk fashion"
},
@@ -167,47 +167,47 @@ CLOTHES_STYLE_DATA = {
# =========================
"Sci-Fi - Streetwear": {
"prompt": "wearing cyberpunk streetwear, neon accents, futuristic urban fashion",
"prompt": "wearing {black|charcoal|white|dark blue|burgundy|violet} cyberpunk streetwear, futuristic urban fashion, {cyan|magenta|violet|electric blue|acid green} neon accents",
"negative_prompt": "medieval clothing, fantasy robes"
},
"Sci-Fi - Tactical Gear": {
"prompt": "wearing cyberpunk tactical gear, armored clothing, urban combat outfit",
"prompt": "wearing {black|charcoal|dark grey|olive|navy blue} cyberpunk tactical gear, armored clothing, {red|cyan|blue|violet|amber} illuminated trim and utility accents",
"negative_prompt": "fantasy plate armor, medieval shields"
},
"Sci-Fi - Corpo Suit": {
"prompt": "wearing cyberpunk corporate suit, sleek futuristic business attire",
"prompt": "wearing {black|charcoal|navy blue|white|deep burgundy} sleek cyberpunk corporate suit, futuristic business attire, {silver|gold|cyan|violet} subtle illuminated trim",
"negative_prompt": "fantasy noble robes, medieval clothing"
},
"Sci-Fi - Leather Jacket": {
"prompt": "wearing cyberpunk leather jacket, neon trim, dystopian street style",
"prompt": "wearing {black|dark brown|charcoal|deep burgundy|navy blue} cyberpunk leather jacket, dystopian street style, {cyan|magenta|violet|red|electric blue} neon trim",
"negative_prompt": "medieval leather armor"
},
"Sci-Fi - Long Trench Coat": {
"prompt": "wearing long cyberpunk trench coat, dystopian noir fashion",
"prompt": "wearing {black|charcoal|deep burgundy|navy blue|dark brown} long cyberpunk trench coat, dystopian noir fashion, {red|cyan|violet|electric blue} subtle illuminated trim",
"negative_prompt": "fantasy cloaks, medieval coats"
},
"Sci-Fi - Combat Armor": {
"prompt": "wearing cyberpunk combat armor, lightweight futuristic plating",
"prompt": "wearing {black|gunmetal|silver|white|dark grey} cyberpunk combat armor, lightweight futuristic plating, {red|cyan|blue|violet|amber} illuminated accents",
"negative_prompt": "knight armor, medieval plate"
},
"Sci-Fi - Techwear": {
"prompt": "wearing cyberpunk techwear, modular clothing, utility-focused fashion",
"prompt": "wearing {black|charcoal|dark grey|olive|navy blue} cyberpunk techwear, modular utility-focused clothing, {cyan|magenta|violet|red|electric blue} technical accents",
"negative_prompt": "fantasy robes, medieval belts"
},
"Sci-Fi - Nomad Gear": {
"prompt": "wearing cyberpunk nomad gear, rugged futuristic clothing",
"prompt": "wearing {charcoal|black|brown|olive|dark green|sand} rugged cyberpunk nomad gear, layered futuristic clothing, {amber|red|cyan|blue} utility accents",
"negative_prompt": "fantasy ranger outfit"
},
"Sci-Fi - Nightclub Outfit": {
"prompt": "wearing cyberpunk nightclub outfit, neon fabrics, futuristic fashion",
"prompt": "wearing {black|white|deep purple|burgundy|midnight blue} cyberpunk nightclub outfit, futuristic fashion, {magenta|cyan|violet|electric blue|neon green} luminous fabric accents",
"negative_prompt": "medieval dress, fantasy costume"
},
+13 -13
View File
@@ -3,39 +3,39 @@ HAIR_STYLE_DATA = {
# AD&D / Base Hair
# =========================
"Bald": {"prompt": "with a bald head", "negative_prompt": "hair on head"},
"Afro": {"prompt": "with Afro Hair", "negative_prompt": ""},
"Afro": {"prompt": "with {short|medium|large} Afro hair, {rounded|full and natural|softly textured}", "negative_prompt": ""},
"Beehive haircut": {"prompt": "with Beehive haircut", "negative_prompt": ""},
"Bleached spikes": {"prompt": "with Bleached spikes", "negative_prompt": ""},
"Blunt bob": {"prompt": "with Blunt bob", "negative_prompt": ""},
"Bob": {"prompt": "with Bob haircut", "negative_prompt": ""},
"Bowl cut": {"prompt": "with bowl cut hair", "negative_prompt": ""},
"Box braids": {"prompt": "with Box braids", "negative_prompt": ""},
"Bushy": {"prompt": "with Bushy hair", "negative_prompt": ""},
"Bushy": {"prompt": "with {short|medium|long} bushy hair, {thick|voluminous|slightly unruly}", "negative_prompt": ""},
"Buzz cut": {"prompt": "with Buzz cut", "negative_prompt": ""},
"Chignon": {"prompt": "with Chignon", "negative_prompt": ""},
"Cloudy": {"prompt": "with Cloudy hair", "negative_prompt": ""},
"Cloudy": {"prompt": "with {soft|voluminous|billowing} cloudy hair, {wavy|curly|fluffy} texture", "negative_prompt": ""},
"Cornrows Hair": {"prompt": "with Cornrows Hair", "negative_prompt": ""},
"Crimped": {"prompt": "with Crimped hair", "negative_prompt": ""},
"Curly bob": {"prompt": "with Curly bob", "negative_prompt": ""},
"Curly Frizzy": {"prompt": "with Curly Frizzy Hair", "negative_prompt": ""},
"Curly Frizzy": {"prompt": "with {short|medium|long} curly frizzy hair, {voluminous|softly textured|wild and natural}", "negative_prompt": ""},
"Double Bun": {"prompt": "with Double Bun", "negative_prompt": ""},
"Dreadlocks": {"prompt": "with Dreadlocks", "negative_prompt": ""},
"Dreadlocks": {"prompt": "with {short|medium|long} dreadlocks, {neatly maintained|thick and natural|slightly loose}", "negative_prompt": ""},
"Fishtail braid": {"prompt": "with Fishtail braid", "negative_prompt": ""},
"French twist": {"prompt": "with French twist", "negative_prompt": ""},
"High Ponytail": {"prompt": "with High Ponytail", "negative_prompt": ""},
"Long ponytail": {"prompt": "with Long ponytail", "negative_prompt": ""},
"Long straight": {"prompt": "with Long straight hair", "negative_prompt": ""},
"Long with bangs": {"prompt": "with Long hair with bangs", "negative_prompt": ""},
"Long ponytail": {"prompt": "with {long|very long} ponytail, {high|mid-height|low} placement and {sleek|wavy|slightly tousled} hair", "negative_prompt": ""},
"Long straight": {"prompt": "with {long|very long} straight hair, {sleek|flowing|slightly wind-swept}", "negative_prompt": ""},
"Long with bangs": {"prompt": "with {long|very long} hair with {straight|soft|wispy} bangs, {sleek|flowing|slightly tousled}", "negative_prompt": ""},
"Loose Curly Afro": {"prompt": "with Loose Curly Afro hair", "negative_prompt": ""},
"Mohawk": {"prompt": "with Mohawk", "negative_prompt": ""},
"Pixie cut": {"prompt": "with Pixie cut", "negative_prompt": ""},
"Pompadour haircut": {"prompt": "with Pompadour haircut", "negative_prompt": ""},
"Quiff haircut": {"prompt": "with Quiff haircut", "negative_prompt": ""},
"Ringlets": {"prompt": "with Hair ringlets", "negative_prompt": ""},
"Short": {"prompt": "with Short hair", "negative_prompt": ""},
"Short curly": {"prompt": "with Short curly hair", "negative_prompt": ""},
"Short": {"prompt": "with {short|very short} hair, {neatly trimmed|slightly tousled|textured}", "negative_prompt": ""},
"Short curly": {"prompt": "with {short|very short} curly hair, {tight curls|soft curls|textured curls}", "negative_prompt": ""},
"Short curly pixie cut": {"prompt": "with Short curly pixie cut", "negative_prompt": ""},
"Shoulder length straight": {"prompt": "with Shoulder length straight hair", "negative_prompt": ""},
"Shoulder length straight": {"prompt": "with shoulder-length straight hair, {sleek|flowing|slightly tousled}", "negative_prompt": ""},
"Shoulder Length with Bangs": {"prompt": "with Shoulder Length Hair with Bangs", "negative_prompt": ""},
"Side braid": {"prompt": "with Side braid", "negative_prompt": ""},
"Side Part Comb-Over": {"prompt": "with Side Part Comb-Over Hairstyle With High Fade", "negative_prompt": ""},
@@ -45,8 +45,8 @@ HAIR_STYLE_DATA = {
"Twin braids": {"prompt": "with Twin braids", "negative_prompt": ""},
"Twintails": {"prompt": "with Twintails hair", "negative_prompt": ""},
"Updo": {"prompt": "with Updo hair", "negative_prompt": ""},
"Very long wave": {"prompt": "with Very long wave hair", "negative_prompt": ""},
"Wavy": {"prompt": "with Wavy hair", "negative_prompt": ""},
"Very long wave": {"prompt": "with {very long|extremely long} wavy hair, {flowing|voluminous|slightly wind-swept}", "negative_prompt": ""},
"Wavy": {"prompt": "with {short|medium|long} wavy hair, {softly flowing|voluminous|slightly tousled}", "negative_prompt": ""},
# =========================
# Sci-Fi / Futuristic Hairstyles
+2 -2
View File
@@ -25,8 +25,8 @@ RACE_DATA = {
"Orc": {"prompt": "(Solo) 1(Orc with tusks:1.5)", "negative_prompt": ""},
"Half-Orc": {"prompt": "(Solo) 1Half-Orc, (Human:0.6) (Orc:0.4)", "negative_prompt": ""},
"Aarakocra": {"prompt": "(Solo) 1Aarakocra", "negative_prompt": ""},
"Tiefling": {"prompt": "(Solo) 1Tiefling, ({brick red|ruddy tan|human} Skin tone:1.3)", "negative_prompt": ""},
"Aasimar": {"prompt": "(Solo) 1Aasimar, ({Pale|Pale Brown|dark brown|tanned|emerald|gold|silver} Skin tone:1.3) with (Pupil-less:1.8) {pale white|gold|gray|topaz} eyes:1.3)", "negative_prompt": ""},
"Tiefling": {"prompt": "(Solo) 1Tiefling, ({brick red|crimson red|deep crimson|burgundy|dark red|scarlet|maroon} skin pigmentation:1.3)", "negative_prompt": "human skin, face paint, facial markings, red makeup"},
"Aasimar": {"prompt": "(Solo) 1Aasimar, celestial humanoid, divine heritage, ({Pale|Pale Brown|dark brown|tanned|emerald|gold|silver} Skin tone:1.15), with luminous {pale white|gold|gray|topaz} eyes:1.15, subtle celestial features", "negative_prompt": "giant eyes, oversized eyes, single giant eye, cyclops, malformed eyes, distorted eyes"},
"Goblin": {"prompt": "(Solo) 1Goblin, ({Deep Red|Orange|Yellow|Green} Skin tone:1.3)", "negative_prompt": ""},
"Kobold": {"prompt": "(Solo) 1Kobold, ({Reddish Brown|Rusty Black|Blue|White|Green} Squamous Skin tone:1.3) with Burnt {Orange|Red} eyes", "negative_prompt": ""},
"Lizardfolk": {"prompt": "(Solo) 1Lizardfolk, Reptilian like", "negative_prompt": ""},
+412 -33
View File
@@ -1,7 +1,10 @@
# ComfyUI-RPG-Characters - Extended Node for Character Race, Gender, and more
# 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
@@ -19,18 +22,241 @@ from .rpg_character_data.rpg_emotion_data import EMOTION_DATA
from .rpg_character_data.rpg_scene_data import SCENE_DATA
def resolve_prompt_variants_with_trace(text):
pattern = r"\{([^{}]+)\}"
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):
options = match.group(1).split("|")
choice = random.choice(options)
selections.append(choice)
return choice
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
resolved = re.sub(pattern, replacer, text)
return resolved, selections
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:
@@ -51,13 +277,38 @@ class RPGCharacterSelector:
"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")
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,
@@ -74,43 +325,105 @@ class RPGCharacterSelector:
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_DATA[race],
ETHNICITY_DATA[ethnicity],
GENDER_DATA[gender],
AGE_DATA[age],
CLASS_DATA[character_class],
HAIR_STYLE_DATA[hair_style],
HAIR_COLOUR_DATA[hair_colour],
BEARD_STYLE_DATA[beard_style],
BEARD_COLOUR_DATA[beard_colour],
CLOTHES_STYLE_DATA[clothes_style],
AUGMENT_DATA[Augmentations],
EMOTION_DATA[emotion],
SCENE_DATA[scene],
("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 entry in selected_data:
for field_name, selected_name, entry in selected_data:
if entry["prompt"]:
resolved, selections = resolve_prompt_variants_with_trace(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 = resolve_prompt_variants_with_trace(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)
# Enhanced plain scene handling
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."
scene_description = (
f"Scene: Solid {scene.replace('Plain ', '').replace('Chroma key ', '')} "
"Background Only. No objects or distractions."
)
else:
scene_description = f"Scene: {scene}"
@@ -125,17 +438,83 @@ class RPGCharacterSelector:
f"Beard Style: {beard_style}",
f"Beard Colour: {beard_colour}",
f"Clothes Style: {clothes_style}",
f"Augmentations: {Augmentations}",
f"Augmentations: {Augmentations}",
f"Emotion: {emotion}",
scene_description
scene_description,
f"DNA Seed: {dna_seed}",
])
ollama_description = "Create an extreme close-up portrait of " + ", ".join(resolved_prompts) + ", Include Art Style."
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)
return (combined_prompt, combined_negative_prompt, ollama_description, selection_summary)
# 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 = {
+826
View File
@@ -0,0 +1,826 @@
import { app } from "../../../scripts/app.js";
const EXTENSION_NAME = "lethris.rpg_character_selector.dna_locker";
function getWidget(node, name) {
return node.widgets?.find(w => w.name === name);
}
function setWidgetValue(node, name, value) {
const widget = getWidget(node, name);
if (!widget) return;
widget.value = value;
widget.callback?.(value);
}
function randomSeed() {
return Math.floor(Math.random() * 0x100000000);
}
// Lightweight deterministic hash for immediate client-side previews. The
// selected preview is persisted in dna_rerolls, so the backend will use
// exactly what the user sees when Run is pressed.
function previewIndex(seed, locusId, count) {
if (!count) return 0;
let h = (Number(seed) >>> 0) ^ 0x9e3779b9;
const text = String(locusId);
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
h ^= h >>> 13;
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
h ^= h >>> 16;
return (h >>> 0) % count;
}
function refreshAllRandomPreviews(node, seed) {
const groups = node.__dnaGroups || [];
node.__dnaRerolls = {};
node.__dnaSelections = {};
for (const group of groups) {
if (!Array.isArray(group.options) || !group.options.length) continue;
const index = previewIndex(seed, group.id, group.options.length);
const preview = group.options[index];
node.__dnaRerolls[group.id] = { count: 0, avoid: null, preview };
node.__dnaSelections[group.id] = preview;
group.selected = preview;
}
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, ch => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[ch]));
}
const DNA_QUOTES = [
'“Life finds a way.” — Dr. Ian Malcolm',
'“Do not meddle in the affairs of wizards…” — A very sensible wizard',
'“The mixture is stable. Probably.” — Anonymous Alchemist',
'“By my calculations, this should only explode a little.” — Apprentice Alchemist',
'“Randomness is merely destiny with better paperwork.” — The Archivist',
'“One does not simply reroll the paladin.” — Some unfortunate party member',
'“The dice know what you are. We merely document it.” — Dungeon Master',
'“A wizard is never late. Their initiative roll is merely complicated.” — Arcane proverb',
'“There are no mistakes, only highly experimental character builds.” — Alchemical proverb',
'“Your character has been genetically… adventurously optimised.” — The Locker',
];
function randomDnaQuote() {
return DNA_QUOTES[Math.floor(Math.random() * DNA_QUOTES.length)];
}
function normaliseWeights(weights, count) {
const values = Array.from({ length: count }, (_, i) => {
const v = Number(weights?.[String(i)] ?? weights?.[i] ?? 0);
return Number.isFinite(v) ? Math.max(0, v) : 0;
});
const total = values.reduce((a, b) => a + b, 0);
return total > 0 ? values.map(v => v / total) : null;
}
function pickRerollVariant(group, previous) {
const options = Array.isArray(group?.options) ? group.options : [];
if (!options.length) return previous ?? group?.selected ?? "";
const alternatives = options.filter(option => option !== previous);
const pool = alternatives.length ? alternatives : options;
return pool[Math.floor(Math.random() * pool.length)];
}
function rerollStateFor(node, id, selected) {
const state = node.__dnaRerolls?.[id];
const count = typeof state === "object"
? Number(state.count || 0)
: Number(state || 0);
return {
count: (Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) + 1,
avoid: selected,
preview: pickRerollVariant(node.__dnaGroups?.find(g => g.id === id), selected),
};
}
function weightedDescription(options, weights) {
const normalised = normaliseWeights(weights, options.length);
if (!normalised) return "Random";
return options
.map((option, i) => ({ option, weight: normalised[i] }))
.filter(x => x.weight > 0.0005)
.map(x => `${x.option} ${(x.weight * 100).toFixed(0)}%`)
.join(" / ");
}
app.registerExtension({
name: EXTENSION_NAME,
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "RPGCharacterSelector") return;
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
const node = this;
node.__dnaGroups = [];
node.__dnaWeights = {};
node.__dnaPreviewWeights = {};
node.__dnaRerolls = {};
node.__dnaSelections = {};
node.__dnaDrawer = null;
node.__dnaEditor = null;
node.__dnaQuote = null;
// Keep the transport widgets in the graph so the state is saved
// with the workflow, but hide them from the normal node UI.
for (const name of ["dna_seed", "dna_revision", "dna_locks", "dna_weights", "dna_rerolls"]) {
const widget = getWidget(node, name);
if (widget) {
widget.hidden = true;
widget.computeSize = () => [0, -4];
}
}
const lockerButton = node.addWidget(
"button",
"🧬 DNA Locker",
null,
() => toggleDrawer(node)
);
// Move the button to the top of the widget stack.
const index = node.widgets.indexOf(lockerButton);
if (index > 0) {
node.widgets.splice(index, 1);
node.widgets.unshift(lockerButton);
}
node.__dnaLockerButton = lockerButton;
node.setDirtyCanvas(true, true);
};
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
originalOnExecuted?.apply(this, arguments);
// ComfyUI sends custom UI fields as arrays. Accept both the
// current list form and the older/direct-object form so the
// locker remains tolerant of cached outputs and frontend changes.
const rawDna = message?.dna ?? message?.ui?.dna ?? message?.output?.dna;
const dna = Array.isArray(rawDna) ? rawDna[0] : rawDna;
if (!dna || typeof dna !== "object") return;
// If the backend had to materialise the initial -1 seed, persist
// that actual seed back into the hidden widget. Otherwise the
// Locker would display a fresh fake random number every time it
// redraws, and the backend would keep seeing -1 on later queues.
if (Number.isFinite(Number(dna.seed))) {
const seedWidget = getWidget(this, "dna_seed");
if (seedWidget && Number(seedWidget.value) !== Number(dna.seed)) {
seedWidget.value = Number(dna.seed);
}
}
this.__dnaGroups = Array.isArray(dna.groups) ? dna.groups : [];
const storedLocks = parseState(getWidget(this, "dna_locks")?.value);
const embeddedRerolls = parseState(storedLocks.__dna_rerolls);
this.__dnaWeights = parseState(getWidget(this, "dna_weights")?.value);
this.__dnaPreviewWeights = {};
const explicitRerolls = parseState(getWidget(this, "dna_rerolls")?.value);
this.__dnaRerolls = Object.keys(explicitRerolls).length
? explicitRerolls
: embeddedRerolls;
// Execution is the authoritative backend result. Clear any
// client-side preview overrides now that the node has caught up.
this.__dnaSelections = {};
if (this.__dnaDrawer) {
renderDrawer(this);
}
};
},
});
function parseState(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function saveState(node) {
const rerolls = node.__dnaRerolls || {};
// dna_locks is retained only as a compatibility transport for older
// workflow payloads. The V2.4 UI no longer exposes or uses lock state.
setWidgetValue(node, "dna_locks", JSON.stringify(
Object.keys(rerolls).length ? { __dna_rerolls: rerolls } : {}
));
setWidgetValue(node, "dna_weights", JSON.stringify(node.__dnaWeights || {}));
setWidgetValue(node, "dna_rerolls", JSON.stringify(rerolls));
// Force ComfyUI/LiteGraph to see DNA edits as execution-input changes.
const revisionWidget = getWidget(node, "dna_revision");
const previousRevision = Number(revisionWidget?.value ?? 0);
const nextRevision = Number.isFinite(previousRevision)
? (previousRevision >= 2147483647 ? 0 : Math.floor(previousRevision) + 1)
: 1;
setWidgetValue(node, "dna_revision", nextRevision);
node.setDirtyCanvas?.(true, true);
node.graph?.change?.();
app.graph?.change?.();
app.graph?.setDirtyCanvas?.(true, true);
}
function closeDrawer(node) {
if (node.__dnaEditorGroupId) {
delete node.__dnaPreviewWeights?.[node.__dnaEditorGroupId];
}
if (node.__dnaDrawer) {
node.__dnaDrawer.remove();
node.__dnaDrawer = null;
}
node.__dnaEditor = null;
node.__dnaEditorGroupId = null;
}
function toggleDrawer(node) {
if (node.__dnaDrawer) {
closeDrawer(node);
return;
}
const drawer = document.createElement("div");
drawer.className = "rpg-dna-drawer";
node.__dnaQuote = randomDnaQuote();
Object.assign(drawer.style, {
position: "fixed",
top: "0",
right: "0",
width: "390px",
height: "100vh",
zIndex: "100000",
background: "var(--comfy-menu-bg, #202020)",
color: "var(--input-text, #ddd)",
boxShadow: "-8px 0 24px rgba(0,0,0,.45)",
borderLeft: "1px solid rgba(255,255,255,.12)",
fontFamily: "Arial, sans-serif",
display: "flex",
flexDirection: "column",
});
node.__dnaDrawer = drawer;
document.body.appendChild(drawer);
renderDrawer(node);
}
function renderDrawer(node) {
const drawer = node.__dnaDrawer;
if (!drawer) return;
const seedWidget = getWidget(node, "dna_seed");
const rawSeed = Number(seedWidget?.value ?? -1);
const seed = Number.isFinite(rawSeed) && rawSeed >= 0 ? rawSeed : null;
const groups = node.__dnaGroups || [];
drawer.innerHTML = `
<div style="padding:16px 16px 12px; border-bottom:1px solid rgba(255,255,255,.12);">
<div style="font-size:19px;font-weight:700;">🧬 DNA LOCKER</div>
<div style="font-size:12px;opacity:.65;margin-top:4px;">
Reproducible character DNA &amp; variant control
</div>
<div style="font-size:11px;line-height:1.45;margin-top:9px;padding:8px 9px;border-left:2px solid rgba(255,255,255,.22);background:rgba(255,255,255,.035);border-radius:0 4px 4px 0;font-style:italic;opacity:.78;">
${escapeHtml(node.__dnaQuote || randomDnaQuote())}
</div>
</div>
<div style="padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.12);">
<div style="font-size:11px;text-transform:uppercase;opacity:.65;margin-bottom:6px;">Master Seed</div>
<div style="display:flex;gap:6px;">
<input id="rpg-dna-seed" type="number" min="0" max="4294967295"
value="${seed !== null ? seed : ""}"
placeholder="Random seed will be assigned on first Run"
style="flex:1;min-width:0;background:#111;color:#eee;border:1px solid #555;border-radius:4px;padding:7px;">
<button id="rpg-dna-random" title="Generate a new seed">🎲</button>
<button id="rpg-dna-apply" title="Apply seed">✓</button>
</div>
<div style="font-size:11px;opacity:.55;margin-top:6px;">
Master seed anchors the DNA sequence. Re-roll changes one trait; Sculpt gives you direct control.
</div>
</div>
<div id="rpg-dna-groups" style="flex:1;overflow:auto;padding:10px 12px;">
${renderGroups(node, groups)}
</div>
<div style="padding:10px 16px;border-top:1px solid rgba(255,255,255,.12);font-size:11px;opacity:.55;">
🎲 re-rolls a trait &nbsp; • &nbsp; 🧬 opens the DNA sculptor
</div>
`;
drawer.querySelector("#rpg-dna-random")?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const value = randomSeed();
drawer.querySelector("#rpg-dna-seed").value = value;
setWidgetValue(node, "dna_seed", value);
node.__dnaWeights = {};
node.__dnaPreviewWeights = {};
refreshAllRandomPreviews(node, value);
saveState(node);
renderDrawer(node);
});
drawer.querySelector("#rpg-dna-apply")?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const value = Number(drawer.querySelector("#rpg-dna-seed").value);
if (!Number.isFinite(value)) return;
const seed = Math.max(0, Math.min(4294967295, Math.floor(value)));
setWidgetValue(node, "dna_seed", seed);
node.__dnaWeights = {};
node.__dnaPreviewWeights = {};
refreshAllRandomPreviews(node, seed);
saveState(node);
renderDrawer(node);
});
bindGroupActions(node, drawer);
}
function refreshGroupCards(node) {
const groupsEl = node.__dnaDrawer?.querySelector("#rpg-dna-groups");
if (!groupsEl) return;
groupsEl.innerHTML = renderGroups(node, node.__dnaGroups || []);
bindGroupActions(node, groupsEl);
}
function bindGroupActions(node, root) {
root.querySelectorAll("[data-dna-sculpt]").forEach(button => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const group = (node.__dnaGroups || []).find(g => g.id === button.dataset.dnaSculpt);
if (group) openSculptor(node, group);
});
});
root.querySelectorAll("[data-dna-reroll]").forEach(button => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const id = button.dataset.dnaReroll;
const group = (node.__dnaGroups || []).find(g => g.id === id);
if (!group) return;
const current = node.__dnaSelections[id] ?? group.selected;
delete node.__dnaWeights[id];
delete node.__dnaPreviewWeights?.[id];
node.__dnaRerolls[id] = rerollStateFor(node, id, current);
node.__dnaSelections[id] = node.__dnaRerolls[id].preview;
group.selected = node.__dnaSelections[id];
saveState(node);
renderDrawer(node);
});
});
}
function renderGroups(node, groups) {
if (!groups.length) {
return `
<div style="padding:22px 8px;text-align:center;opacity:.65;">
<div style="font-size:30px;">🧬</div>
<div style="margin-top:8px;">No random DNA loci were found.</div>
<div style="font-size:11px;margin-top:5px;">
Fixed options such as Orc or Half-Orc simply have no DNA controls.
</div>
</div>
`;
}
const byField = {};
for (const group of groups) {
(byField[group.field] ||= []).push(group);
}
return Object.entries(byField).map(([field, fieldGroups]) => `
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:13px;padding:5px 4px;opacity:.9;">${escapeHtml(field)}</div>
${fieldGroups.map(group => {
const committedWeights = node.__dnaWeights[group.id];
const previewWeights = node.__dnaPreviewWeights?.[group.id];
const activeWeights = previewWeights || committedWeights;
const sculpted = !!activeWeights;
const rerollState = node.__dnaRerolls[group.id];
const rerollPreview = rerollState && typeof rerollState === "object"
? rerollState.preview
: null;
const mode = sculpted ? "sculpted" : "random";
const liveSelection = node.__dnaSelections[group.id];
const selected = sculpted
? weightedDescription(group.options, activeWeights)
: liveSelection && group.options.includes(liveSelection)
? liveSelection
: (rerollPreview && group.options.includes(rerollPreview))
? rerollPreview
: group.selected;
return `
<div style="background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.09);border-radius:6px;padding:9px;margin:5px 0;">
<div style="display:flex;align-items:center;gap:6px;">
<div style="flex:1;font-size:12px;font-weight:600;">${escapeHtml(group.label)}</div>
<span style="font-size:10px;opacity:.55;">${mode === "sculpted" ? "🧬 SCULPTED" : "RANDOM"}</span>
</div>
<div style="font-size:12px;margin:7px 0;color:#fff;">${escapeHtml(selected)}</div>
<div style="display:flex;gap:5px;">
<button type="button" data-dna-reroll="${escapeHtml(group.id)}" title="Reroll this DNA locus" style="flex:1;">🎲 Re-roll</button>
<button type="button" data-dna-sculpt="${escapeHtml(group.id)}" title="Open DNA sculptor" style="flex:1;">🧬 Sculpt</button>
</div>
</div>
`;
}).join("")}
</div>
`).join("");
}
function makeAnchorPositions(count, width = 340, height = 250) {
if (count <= 0) return [];
if (count === 1) return [{ x: width / 2, y: height / 2 }];
if (count === 2) return [
{ x: 62, y: height / 2 },
{ x: width - 62, y: height / 2 },
];
const cx = width / 2;
const cy = height / 2;
const rx = Math.min(width * 0.40, 138);
const ry = Math.min(height * 0.39, 88);
const offset = -Math.PI / 2;
return Array.from({ length: count }, (_, i) => {
const angle = offset + (Math.PI * 2 * i / count);
return {
x: cx + Math.cos(angle) * rx,
y: cy + Math.sin(angle) * ry,
};
});
}
function pointFromWeights(group, weights, anchors) {
const normalised = normaliseWeights(weights, group.options.length);
if (!normalised) return { x: 170, y: 125 };
let x = 0;
let y = 0;
let total = 0;
normalised.forEach((weight, i) => {
if (anchors[i]) {
x += anchors[i].x * weight;
y += anchors[i].y * weight;
total += weight;
}
});
return total > 0 ? { x: x / total, y: y / total } : { x: 170, y: 125 };
}
function nearestThreeWeights(point, anchors) {
if (!anchors.length) return [];
if (anchors.length === 1) return [1];
const distances = anchors.map((anchor, index) => ({
index,
distance: Math.hypot(point.x - anchor.x, point.y - anchor.y),
})).sort((a, b) => a.distance - b.distance);
const nearest = distances.slice(0, Math.min(3, anchors.length));
if (nearest[0].distance < 0.001) {
return anchors.map((_, i) => i === nearest[0].index ? 1 : 0);
}
// Inverse-distance weighting gives a smooth "DNA gravity" field while
// deliberately discarding every option outside the nearest three.
const raw = nearest.map(item => 1 / Math.pow(item.distance, 2));
const total = raw.reduce((a, b) => a + b, 0);
const weights = Array(anchors.length).fill(0);
nearest.forEach((item, i) => {
weights[item.index] = raw[i] / total;
});
return weights;
}
function formatWeight(value) {
return Number(value).toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
}
function openSculptor(node, group) {
if (!node.__dnaDrawer) return;
const existing = node.__dnaEditor;
if (existing) {
delete node.__dnaPreviewWeights?.[node.__dnaEditorGroupId];
existing.remove();
node.__dnaEditor = null;
node.__dnaEditorGroupId = null;
refreshGroupCards(node);
}
const editor = document.createElement("div");
node.__dnaEditor = editor;
node.__dnaEditorGroupId = group.id;
Object.assign(editor.style, {
position: "absolute",
left: "0",
right: "0",
bottom: "0",
maxHeight: "82vh",
overflow: "auto",
background: "var(--comfy-menu-bg, #202020)",
borderTop: "1px solid rgba(255,255,255,.15)",
boxShadow: "0 -10px 30px rgba(0,0,0,.5)",
padding: "14px 16px 18px",
zIndex: "5",
});
const width = 340;
const height = 250;
const anchors = makeAnchorPositions(group.options.length, width, height);
const currentWeights = node.__dnaPreviewWeights?.[group.id] || node.__dnaWeights[group.id];
const startPoint = currentWeights
? pointFromWeights(group, currentWeights, anchors)
: (anchors[group.options.indexOf(group.selected)] || anchors[0]);
editor.innerHTML = `
<style>
.rpg-dna-space { position:relative; width:100%; max-width:${width}px; height:${height}px; margin:8px auto 10px; }
.rpg-dna-space svg { width:100%; height:100%; display:block; overflow:visible; }
.rpg-dna-anchor { fill:rgba(210,210,210,.12); stroke:rgba(255,255,255,.35); stroke-width:1.5; }
.rpg-dna-anchor.active { fill:rgba(190,235,255,.22); stroke:rgba(210,245,255,.9); }
.rpg-dna-label { font:11px Arial,sans-serif; fill:rgba(255,255,255,.82); pointer-events:none; }
.rpg-dna-link { stroke:rgba(180,225,255,.75); stroke-width:1.5; stroke-dasharray:5 4; opacity:0; }
.rpg-dna-link.active { animation:rpg-dna-link-pulse .75s ease-out; }
@keyframes rpg-dna-link-pulse { 0% { opacity:0; stroke-width:3; } 18% { opacity:1; } 100% { opacity:.18; stroke-width:1.2; } }
.rpg-dna-core { fill:rgba(220,245,255,.96); stroke:rgba(255,255,255,.95); stroke-width:2; filter:drop-shadow(0 0 6px rgba(170,225,255,.9)); cursor:grab; }
.rpg-dna-core.dragging { cursor:grabbing; }
.rpg-dna-spark { fill:rgba(225,250,255,.95); opacity:0; pointer-events:none; }
.rpg-dna-hint { text-align:center; font-size:10px; opacity:.5; margin-top:-4px; }
</style>
<div style="display:flex;align-items:center;gap:8px;">
<div style="font-weight:700;flex:1;">🧬 ${escapeHtml(group.label)}</div>
<button id="rpg-dna-cancel" title="Cancel sculpting" style="min-width:34px;color:#f99;">✕</button>
<button id="rpg-dna-apply-sculpt" title="Apply sculpted DNA" style="min-width:34px;color:#9f9;font-weight:700;">✓</button>
</div>
<div style="font-size:11px;opacity:.6;margin:5px 0 8px;">
Move the DNA point. It blends the <b>nearest three</b> variants.
The closer the point is to a variant, the stronger its influence.
Changes preview live in the Locker. <b>✓</b> applies them; <b>✕</b> cancels them.
</div>
<div class="rpg-dna-space">
<svg id="rpg-dna-svg" viewBox="0 0 ${width} ${height}" aria-label="DNA sculpting space"></svg>
</div>
<div class="rpg-dna-hint">✨ Drag the point through the DNA field</div>
<div id="rpg-dna-options" style="margin-top:10px;"></div>
<div id="rpg-dna-result" style="margin-top:10px;padding:9px;background:#111;border-radius:4px;font-size:11px;word-break:break-word;"></div>
`;
node.__dnaDrawer.appendChild(editor);
const svg = editor.querySelector("#rpg-dna-svg");
const optionsEl = editor.querySelector("#rpg-dna-options");
const result = editor.querySelector("#rpg-dna-result");
const ns = "http://www.w3.org/2000/svg";
const links = anchors.map((_, i) => {
const line = document.createElementNS(ns, "line");
line.classList.add("rpg-dna-link");
line.dataset.index = String(i);
svg.appendChild(line);
return line;
});
anchors.forEach((anchor, i) => {
const circle = document.createElementNS(ns, "circle");
circle.classList.add("rpg-dna-anchor");
circle.setAttribute("cx", anchor.x);
circle.setAttribute("cy", anchor.y);
circle.setAttribute("r", 8);
circle.dataset.index = String(i);
svg.appendChild(circle);
const label = document.createElementNS(ns, "text");
label.classList.add("rpg-dna-label");
label.setAttribute("x", anchor.x);
label.setAttribute("y", anchor.y + (anchor.y < height / 2 ? -13 : 23));
label.setAttribute("text-anchor", "middle");
label.textContent = group.options[i];
svg.appendChild(label);
});
const sparkLayer = document.createElementNS(ns, "g");
svg.appendChild(sparkLayer);
const core = document.createElementNS(ns, "circle");
core.classList.add("rpg-dna-core");
core.setAttribute("r", 7);
svg.appendChild(core);
let point = {
x: Math.max(8, Math.min(width - 8, startPoint.x)),
y: Math.max(8, Math.min(height - 8, startPoint.y)),
};
let dragging = false;
let fadeTimer = null;
let sparkFrame = null;
let lastSpark = 0;
optionsEl.innerHTML = group.options.map((option, i) => `
<div data-option-row="${i}" style="display:flex;align-items:center;gap:7px;margin:4px 0;">
<div style="width:110px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(option)}</div>
<div style="flex:1;height:7px;background:rgba(255,255,255,.1);border-radius:5px;overflow:hidden;">
<div data-bar="${i}" style="height:100%;width:0%;background:#aaa;transition:width .08s ease;"></div>
</div>
<div data-pct="${i}" style="width:40px;text-align:right;font-size:10px;opacity:.7;">0%</div>
</div>
`).join("");
function setPointFromEvent(event) {
const rect = svg.getBoundingClientRect();
const scaleX = width / rect.width;
const scaleY = height / rect.height;
point.x = Math.max(8, Math.min(width - 8, (event.clientX - rect.left) * scaleX));
point.y = Math.max(8, Math.min(height - 8, (event.clientY - rect.top) * scaleY));
}
function spawnSparkles() {
const now = performance.now();
if (now - lastSpark < 35) return;
lastSpark = now;
for (let i = 0; i < 3; i++) {
const spark = document.createElementNS(ns, "circle");
spark.classList.add("rpg-dna-spark");
spark.setAttribute("cx", point.x);
spark.setAttribute("cy", point.y);
spark.setAttribute("r", String(1 + Math.random() * 2));
sparkLayer.appendChild(spark);
const sx = point.x;
const sy = point.y;
const dx = (Math.random() - 0.5) * 22;
const dy = (Math.random() - 0.5) * 22;
const born = performance.now();
const duration = 300 + Math.random() * 260;
const animate = (t) => {
const progress = Math.min(1, (t - born) / duration);
spark.setAttribute("cx", sx + dx * progress);
spark.setAttribute("cy", sy + dy * progress);
spark.style.opacity = String(1 - progress);
if (progress < 1 && spark.isConnected) {
requestAnimationFrame(animate);
} else {
spark.remove();
}
};
requestAnimationFrame(animate);
}
}
function updateVisuals(showLinks = dragging) {
const weightsArray = nearestThreeWeights(point, anchors);
const weights = {};
weightsArray.forEach((value, i) => weights[String(i)] = value);
// Sculpting is a live preview until the user clicks ✓. Keep it
// separate from committed DNA so ✕ can genuinely cancel.
node.__dnaPreviewWeights[group.id] = weights;
refreshGroupCards(node);
core.setAttribute("cx", point.x);
core.setAttribute("cy", point.y);
core.classList.toggle("dragging", dragging);
anchors.forEach((anchor, i) => {
const active = (weightsArray[i] || 0) > 0.0005;
const circle = svg.querySelector(`.rpg-dna-anchor[data-index="${i}"]`);
circle?.classList.toggle("active", active);
const line = links[i];
line.setAttribute("x1", point.x);
line.setAttribute("y1", point.y);
line.setAttribute("x2", anchor.x);
line.setAttribute("y2", anchor.y);
line.classList.remove("active");
if (showLinks && active) {
// Force the CSS animation to restart on every meaningful drag update.
void line.getBoundingClientRect();
line.classList.add("active");
}
});
group.options.forEach((_, i) => {
const pct = Math.round((weightsArray[i] || 0) * 100);
editor.querySelector(`[data-bar="${i}"]`).style.width = `${pct}%`;
editor.querySelector(`[data-pct="${i}"]`).textContent = `${pct}%`;
});
const parts = group.options
.map((option, i) => {
const w = weightsArray[i] || 0;
return w > 0.0005 ? `(${option}:${formatWeight(w)})` : null;
})
.filter(Boolean);
result.textContent = parts.length > 1 ? parts.join(" & ") : (parts[0] || group.selected);
}
function startLinkFade() {
clearTimeout(fadeTimer);
fadeTimer = setTimeout(() => {
links.forEach(line => {
line.style.transition = "opacity .65s ease";
line.style.opacity = "0";
line.classList.remove("active");
});
}, 180);
}
function dragMove(event) {
if (!dragging) return;
setPointFromEvent(event);
updateVisuals(true);
spawnSparkles();
event.preventDefault();
}
core.addEventListener("pointerdown", event => {
dragging = true;
core.setPointerCapture?.(event.pointerId);
setPointFromEvent(event);
updateVisuals(true);
spawnSparkles();
event.preventDefault();
});
svg.addEventListener("pointermove", dragMove);
svg.addEventListener("pointerdown", event => {
if (event.target === core) return;
dragging = true;
svg.setPointerCapture?.(event.pointerId);
setPointFromEvent(event);
updateVisuals(true);
spawnSparkles();
event.preventDefault();
});
const stopDragging = () => {
if (!dragging) return;
dragging = false;
core.classList.remove("dragging");
startLinkFade();
};
svg.addEventListener("pointerup", stopDragging);
svg.addEventListener("pointercancel", stopDragging);
svg.addEventListener("pointerleave", event => {
if (dragging && !svg.hasPointerCapture?.(event.pointerId)) stopDragging();
});
editor.querySelector("#rpg-dna-cancel").addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
delete node.__dnaPreviewWeights[group.id];
editor.remove();
node.__dnaEditor = null;
node.__dnaEditorGroupId = null;
refreshGroupCards(node);
});
editor.querySelector("#rpg-dna-apply-sculpt").addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const preview = node.__dnaPreviewWeights?.[group.id];
if (preview) {
node.__dnaWeights[group.id] = preview;
} else {
delete node.__dnaWeights[group.id];
}
delete node.__dnaPreviewWeights[group.id];
node.__dnaEditorGroupId = null;
saveState(node);
editor.remove();
node.__dnaEditor = null;
renderDrawer(node);
});
updateVisuals(false);
}