Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c640b7e966 | ||
|
|
4cc0e8737e | ||
|
|
d74c38ecc1 | ||
|
|
b04a069841 | ||
|
|
981ff6bb63 | ||
|
|
f482712a6c | ||
|
|
c32eaf19c7 | ||
|
|
6517c951fa | ||
|
|
53dedad874 |
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 |
@@ -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:**
|
||||

|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+4
-24
@@ -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
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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 => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[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 & 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 • 🧬 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user