From e35ec29f4fa3d42909a486944532d6cdcd50bc07 Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Fri, 27 Feb 2026 08:42:22 -0800 Subject: [PATCH] Add CharacterPrompts nodes and web UI Introduce a new CharacterPrompts extension: adds node implementations (Character Create, Character Edit/Delete, Character Prompt Encode) with storage, resolver, and README. Adds persistent character library storage (default/ComfyUI-CharacterPrompts-MXD/characters.json), server route (/mxd/characters/list), and a web UI integration (mxd_character_prompts.js + import) to refresh/pick characters in the graph. Update root __init__ to register the new module. Also remove the Grow Blur Mask MXD node from maxedoutnodes, and bump package version to 1.9.0 in pyproject.toml. --- CharacterPrompts/README.md | 56 ++++++++ CharacterPrompts/__init__.py | 3 + CharacterPrompts/nodes.py | 244 +++++++++++++++++++++++++++++++++++ CharacterPrompts/resolver.py | 145 +++++++++++++++++++++ CharacterPrompts/storage.py | 193 +++++++++++++++++++++++++++ __init__.py | 10 +- maxedoutnodes.py | 87 ------------- pyproject.toml | 4 +- web/index.js | 1 + web/mxd_character_prompts.js | 176 +++++++++++++++++++++++++ 10 files changed, 827 insertions(+), 92 deletions(-) create mode 100644 CharacterPrompts/README.md create mode 100644 CharacterPrompts/__init__.py create mode 100644 CharacterPrompts/nodes.py create mode 100644 CharacterPrompts/resolver.py create mode 100644 CharacterPrompts/storage.py create mode 100644 web/mxd_character_prompts.js diff --git a/CharacterPrompts/README.md b/CharacterPrompts/README.md new file mode 100644 index 0000000..c302066 --- /dev/null +++ b/CharacterPrompts/README.md @@ -0,0 +1,56 @@ +# ComfyUI-CharacterPrompts-MXD + +Beginner-friendly character alias nodes for ComfyUI. + +## What This Adds + +1. `Character Create` +- Create a new character or update an existing one by name. + +2. `Character Edit/Delete` +- Pick from a dropdown of saved characters. +- Includes a refresh button on the dropdown. +- Load selected character data, save prompt changes, or delete selected. + +3. `Character Prompt Encode` +- Resolves character names in your prompt (`Homayun`) and tags (`@Homayun`). +- Replaces references with the saved character description text. +- Outputs `CONDITIONING` directly for sampler wiring. +- Supports multi-character handling modes for cleaner multi-character prompts. + +## Storage Location + +Character library is shared globally at: + +`/default/ComfyUI-CharacterPrompts-MXD/characters.json` + +Example: + +`ComfyUI/user/default/ComfyUI-CharacterPrompts-MXD/characters.json` + +## Quick Start + +1. Add `Character Create`. +2. Fill: +- `character_name`: `Homayun` +- `character_prompt`: your full character description +3. Queue once to save. +4. Optional edits/deletes: +- Add `Character Edit/Delete`. +- Pick character from dropdown. +- Use refresh button if needed. +5. Add `Character Prompt Encode`. +6. Connect your `CLIP` input. +7. Write a normal scene prompt: +- `Homayun holding flowers` +- or `@Homayun holding flowers` +8. Use `conditioning` output as your positive conditioning. + +## Recommended Settings + +- `character_handling`: `separate_with_break` + +## Notes + +- When 2+ characters are detected and `character_handling` is `separate_with_break` or `combine_conditioning`, each character description is encoded as a separate segment before scene text. +- If fewer than 2 characters are detected, it falls back to a single resolved prompt encode. diff --git a/CharacterPrompts/__init__.py b/CharacterPrompts/__init__.py new file mode 100644 index 0000000..39a8c6b --- /dev/null +++ b/CharacterPrompts/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/CharacterPrompts/nodes.py b/CharacterPrompts/nodes.py new file mode 100644 index 0000000..4a5eeb0 --- /dev/null +++ b/CharacterPrompts/nodes.py @@ -0,0 +1,244 @@ +import logging + +import torch + +from . import resolver, storage + +try: + from aiohttp import web + from server import PromptServer + + HAVE_SERVER = True +except Exception: + web = None + PromptServer = None + HAVE_SERVER = False + + +CHARACTER_LIST_ROUTE = "/mxd/characters/list" +NO_CHARACTERS_OPTION = "(no characters saved)" + + +def _list_character_names() -> list[str]: + schema = storage.load_schema() + return storage.list_character_names(schema) + + +def _selected_character_input(): + names = _list_character_names() + options = names if names else [NO_CHARACTERS_OPTION] + return ( + options, + { + "default": options[0], + "tooltip": "Choose a saved character. Use the refresh button to update this list.", + }, + ) + + +def _is_valid_selected(name: str) -> bool: + return bool(name and name != NO_CHARACTERS_OPTION) + + +if HAVE_SERVER: + routes = PromptServer.instance.routes + + @routes.get(CHARACTER_LIST_ROUTE) + async def mxd_character_prompt_list(_request): + names = _list_character_names() + if not names: + names = [NO_CHARACTERS_OPTION] + return web.json_response(names) + + +def _encode_text(clip, text: str): + tokens = clip.tokenize(text) + return clip.encode_from_tokens_scheduled(tokens) + + +def _conditioning_concat(conditioning_to, conditioning_from): + out = [] + if len(conditioning_from) > 1: + logging.warning( + "MXD Character Prompt Encode: concat source contains more than one condition; using first condition only." + ) + + cond_from = conditioning_from[0][0] + for i in range(len(conditioning_to)): + cond_to = conditioning_to[i][0] + merged = torch.cat((cond_to, cond_from), 1) + out.append([merged, conditioning_to[i][1].copy()]) + return out + + +def _conditioning_combine(conditioning_1, conditioning_2): + return conditioning_1 + conditioning_2 + + +def _encode_segments(clip, segments: list[str], break_mode: str): + if not segments: + return _encode_text(clip, "") + conditioning = _encode_text(clip, segments[0]) + for segment in segments[1:]: + segment_cond = _encode_text(clip, segment) + if break_mode == "concat_characters_then_scene": + conditioning = _conditioning_concat(conditioning, segment_cond) + elif break_mode == "combine_characters_then_scene": + conditioning = _conditioning_combine(conditioning, segment_cond) + return conditioning + + +class MXDCharacterCreate: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "character_name": ("STRING", {"default": "", "multiline": False}), + "character_prompt": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status",) + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "MXD/Character" + TITLE = "Character Create" + + def run(self, character_name, character_prompt): + try: + canonical = storage.normalize_key(character_name) + existing_schema = storage.load_schema() + existed = canonical in existing_schema["characters"] + schema, entry = storage.save_or_update_character( + character_name=character_name, + character_prompt=character_prompt, + aliases_csv=None, + ) + count = len(schema["characters"]) + action = "Updated" if existed else "Created" + status = f"{action} character '{entry['name']}' ({count} total)." + return (status,) + except Exception as error: + return (f"Error: {error}",) + + +class MXDCharacterEditDelete: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "selected_character": _selected_character_input(), + "action": ( + ["load_character", "edit_character", "delete_character"], + {"default": "load_character"}, + ), + "character_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": "Used when action=edit_character.", + }, + ), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("character_prompt",) + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "MXD/Character" + TITLE = "Character Edit/Delete" + + def run(self, selected_character, action, character_prompt): + try: + if not _is_valid_selected(selected_character): + return ("",) + + if action == "load_character": + _schema, _canonical, entry = storage.load_character(selected_character) + return (entry["prompt"],) + + if action == "edit_character": + _, _canonical, current_entry = storage.load_character(selected_character) + _schema, updated_entry = storage.save_or_update_character( + character_name=current_entry["name"], + character_prompt=character_prompt, + aliases_csv=None, + ) + return (updated_entry["prompt"],) + + storage.delete_character(selected_character) + return ("",) + except Exception: + return ("",) + + +class MXDCharacterPromptEncode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "clip": ("CLIP",), + "prompt": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": True}), + "character_handling": ( + ["separate_with_break", "combine_conditioning", "single_prompt"], + {"default": "separate_with_break"}, + ), + } + } + + RETURN_TYPES = ("CONDITIONING", "STRING") + RETURN_NAMES = ("conditioning", "prompt_with_character_text") + FUNCTION = "encode" + CATEGORY = "MXD/Character" + TITLE = "Character Prompt Encode" + + def encode(self, clip, prompt, character_handling): + if clip is None: + raise RuntimeError( + "ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model." + ) + + schema = storage.load_schema() + characters = schema["characters"] + + resolved = resolver.resolve_prompt( + prompt=prompt, + characters=characters, + reference_mode="plain_only", + missing_behavior="warn_and_keep_text", + ) + + used_prompts = [] + for canonical in resolved.used_canonical_names: + entry = characters.get(canonical) + if entry and entry.get("prompt"): + used_prompts.append(entry["prompt"]) + + if character_handling != "single_prompt" and len(used_prompts) >= 2: + segments = used_prompts + [resolved.scene_prompt] + if character_handling == "separate_with_break": + break_mode = "concat_characters_then_scene" + else: + break_mode = "combine_characters_then_scene" + conditioning = _encode_segments(clip, segments, break_mode) + else: + conditioning = _encode_text(clip, resolved.resolved_prompt) + + return (conditioning, resolved.resolved_prompt) + + +NODE_CLASS_MAPPINGS = { + "MXDCharacterCreate": MXDCharacterCreate, + "MXDCharacterEditDelete": MXDCharacterEditDelete, + "MXDCharacterPromptEncode": MXDCharacterPromptEncode, +} + + +NODE_DISPLAY_NAME_MAPPINGS = { + "MXDCharacterCreate": "Character Create MXD", + "MXDCharacterEditDelete": "Character Edit/Delete MXD", + "MXDCharacterPromptEncode": "Character Prompt Encode MXD", +} diff --git a/CharacterPrompts/resolver.py b/CharacterPrompts/resolver.py new file mode 100644 index 0000000..4d129ab --- /dev/null +++ b/CharacterPrompts/resolver.py @@ -0,0 +1,145 @@ +import logging +import re +from dataclasses import dataclass + +from .storage import normalize_key + + +@dataclass +class ResolveResult: + resolved_prompt: str + scene_prompt: str + used_canonical_names: list[str] + missing_tags: list[str] + + +def _build_lookup(characters: dict) -> dict[str, str]: + lookup = {} + for canonical, entry in characters.items(): + lookup[canonical] = canonical + for alias in entry.get("aliases", []): + alias_key = normalize_key(str(alias)) + if alias_key: + lookup[alias_key] = canonical + return lookup + + +def _add_used(used: list[str], canonical: str) -> None: + if canonical not in used: + used.append(canonical) + + +def _scene_prompt_from_original(prompt: str) -> str: + # Keep scene wording intact, only drop explicit @ marker. + return re.sub(r"@([A-Za-z0-9_\-]+)", r"\1", prompt) + + +def _find_missing_tags(prompt: str, lookup: dict[str, str]) -> list[str]: + seen = set() + missing = [] + for match in re.finditer(r"@([A-Za-z0-9_\-]+)", prompt): + raw = match.group(1) + key = normalize_key(raw) + if key not in lookup and key not in seen: + missing.append(raw) + seen.add(key) + return missing + + +def _pattern_from_terms(terms: list[str], prefix: str = "", suffix: str = "") -> re.Pattern | None: + if not terms: + return None + escaped = [re.escape(term) for term in terms] + pattern = f"{prefix}({'|'.join(escaped)}){suffix}" + return re.compile(pattern, re.IGNORECASE) + + +def _replace_with_placeholders( + text: str, + pattern: re.Pattern | None, + lookup: dict[str, str], + characters: dict, + used: list[str], + placeholders: dict[str, str], + next_id_start: int = 1, +) -> tuple[str, int]: + if pattern is None: + return text, next_id_start + + next_id = next_id_start + + def callback(match: re.Match) -> str: + nonlocal next_id + matched = normalize_key(match.group(1)) + canonical = lookup.get(matched) + if canonical is None: + return match.group(0) + _add_used(used, canonical) + placeholder = f"__MXD_CHAR_{next_id}__" + next_id += 1 + placeholders[placeholder] = characters[canonical]["prompt"] + return placeholder + + return pattern.sub(callback, text), next_id + + +def _handle_missing_tags(missing: list[str], missing_behavior: str) -> None: + if not missing: + return + missing_text = ", ".join(missing) + if missing_behavior == "error": + raise ValueError(f"Unknown character tags: {missing_text}") + if missing_behavior == "warn_and_keep_text": + logging.warning("MXD Character Prompt Encode: unknown character tags left unchanged: %s", missing_text) + + +def resolve_prompt( + prompt: str, + characters: dict, + reference_mode: str = "support_both", + missing_behavior: str = "warn_and_keep_text", +) -> ResolveResult: + lookup = _build_lookup(characters) + used = [] + placeholders = {} + text = prompt + next_id = 1 + + sorted_terms = sorted(lookup.keys(), key=len, reverse=True) + tag_pattern = _pattern_from_terms(sorted_terms, prefix=r"(? str: + return " ".join(value.strip().lower().split()) + + +def parse_aliases_csv(aliases_csv: str) -> list[str]: + if not aliases_csv: + return [] + aliases = [] + seen = set() + for raw in aliases_csv.split(","): + alias = normalize_key(raw) + if alias and alias not in seen: + aliases.append(alias) + seen.add(alias) + return aliases + + +def get_library_dir() -> str: + return os.path.join(folder_paths.get_user_directory(), "default", EXTENSION_DIRNAME) + + +def get_library_path() -> str: + return os.path.join(get_library_dir(), FILENAME) + + +def _empty_schema() -> dict: + return {"version": SCHEMA_VERSION, "characters": {}} + + +def _write_json_atomic(path: str, data: dict) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp_path = f"{path}.tmp" + with open(tmp_path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=False) + os.replace(tmp_path, path) + + +def _normalize_entry(canonical: str, entry: dict) -> dict: + name = str(entry.get("name", canonical)).strip() or canonical + prompt = str(entry.get("prompt", "")).strip() + aliases_raw = entry.get("aliases", []) + aliases = [] + seen = {canonical} + if isinstance(aliases_raw, list): + for alias_raw in aliases_raw: + alias = normalize_key(str(alias_raw)) + if alias and alias not in seen: + aliases.append(alias) + seen.add(alias) + updated_at = str(entry.get("updated_at", _utc_now())) + return { + "name": name, + "prompt": prompt, + "aliases": aliases, + "updated_at": updated_at, + } + + +def _normalize_schema(raw: dict) -> dict: + if not isinstance(raw, dict): + return _empty_schema() + characters_raw = raw.get("characters", {}) + if not isinstance(characters_raw, dict): + characters_raw = {} + normalized = {} + for key, entry in characters_raw.items(): + canonical = normalize_key(str(key)) + if not canonical or not isinstance(entry, dict): + continue + normalized[canonical] = _normalize_entry(canonical, entry) + return {"version": SCHEMA_VERSION, "characters": normalized} + + +def _backup_corrupt_file(path: str) -> None: + if not os.path.exists(path): + return + backup_path = f"{path}.bak" + try: + if os.path.exists(backup_path): + os.remove(backup_path) + os.replace(path, backup_path) + except OSError: + pass + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def load_schema() -> dict: + path = get_library_path() + if not os.path.exists(path): + schema = _empty_schema() + _write_json_atomic(path, schema) + return schema + + try: + with open(path, "r", encoding="utf-8") as handle: + raw = json.load(handle) + except (OSError, json.JSONDecodeError): + _backup_corrupt_file(path) + schema = _empty_schema() + _write_json_atomic(path, schema) + return schema + + schema = _normalize_schema(raw) + if schema != raw: + _write_json_atomic(path, schema) + return schema + + +def save_schema(schema: dict) -> None: + normalized = _normalize_schema(schema) + _write_json_atomic(get_library_path(), normalized) + + +def list_character_names(schema: dict | None = None) -> list[str]: + schema = schema or load_schema() + names = [entry["name"] for entry in schema["characters"].values()] + return sorted(names, key=lambda value: value.lower()) + + +def save_or_update_character( + character_name: str, + character_prompt: str, + aliases_csv: str | None = "", +) -> tuple[dict, dict]: + canonical = normalize_key(character_name) + if not canonical: + raise ValueError("Character name cannot be empty.") + + prompt = str(character_prompt or "").strip() + if not prompt: + raise ValueError("Character prompt cannot be empty.") + + schema = load_schema() + existing_entry = schema["characters"].get(canonical) + + if aliases_csv is None: + aliases_raw = existing_entry.get("aliases", []) if existing_entry else [] + aliases = [] + seen = {canonical} + for alias_raw in aliases_raw: + alias = normalize_key(str(alias_raw)) + if alias and alias not in seen: + aliases.append(alias) + seen.add(alias) + else: + aliases = [alias for alias in parse_aliases_csv(aliases_csv) if alias != canonical] + + schema["characters"][canonical] = { + "name": character_name.strip() or canonical, + "prompt": prompt, + "aliases": aliases, + "updated_at": _utc_now(), + } + save_schema(schema) + return schema, schema["characters"][canonical] + + +def load_character(character_name: str) -> tuple[dict, str, dict]: + canonical = normalize_key(character_name) + if not canonical: + raise ValueError("Character name cannot be empty.") + schema = load_schema() + entry = schema["characters"].get(canonical) + if entry is None: + raise KeyError(f"Character '{character_name}' was not found.") + return schema, canonical, entry + + +def delete_character(character_name: str) -> tuple[dict, str]: + canonical = normalize_key(character_name) + if not canonical: + raise ValueError("Character name cannot be empty.") + schema = load_schema() + if canonical not in schema["characters"]: + raise KeyError(f"Character '{character_name}' was not found.") + removed_name = schema["characters"][canonical]["name"] + del schema["characters"][canonical] + save_schema(schema) + return schema, removed_name diff --git a/__init__.py b/__init__.py index 5b6ec7a..1fab914 100644 --- a/__init__.py +++ b/__init__.py @@ -19,7 +19,14 @@ def _get_mappings(mod): NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} -for _name in ("maxedoutnodes", "mediacomparers", "wan22nodes", "loraloader_mxd", "wan_svi_first_last_mxd"): +for _name in ( + "maxedoutnodes", + "mediacomparers", + "wan22nodes", + "loraloader_mxd", + "wan_svi_first_last_mxd", + "CharacterPrompts", +): _mod = _safe_import(_name) _class_map, _display_map = _get_mappings(_mod) NODE_CLASS_MAPPINGS.update(_class_map) @@ -30,4 +37,3 @@ __all__ = [ "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY", ] - diff --git a/maxedoutnodes.py b/maxedoutnodes.py index 5c4beb6..f2934d9 100644 --- a/maxedoutnodes.py +++ b/maxedoutnodes.py @@ -798,91 +798,6 @@ class LatentHalfMasks: ######################################################################################################################## -# Grow Blur Mask MXD (single-image friendly) -class GrowBlurMaskMXD: - DESCRIPTION = """Expand or contract a mask and blur only the expanded ring (core stays solid).""" - TITLE = "Grow Blur Mask MXD" - CATEGORY = "MXD/Mask" - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mask": ("MASK",), - "grow_blur": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1}), - } - } - - RETURN_TYPES = ("MASK",) - FUNCTION = "run" - - def _normalize_mask(self, mask: torch.Tensor) -> torch.Tensor: - if not isinstance(mask, torch.Tensor): - raise ValueError("GrowBlurMaskMXD: mask must be a torch.Tensor.") - - if mask.dim() == 2: - mask = mask.unsqueeze(0) - elif mask.dim() == 4 and mask.shape[-1] == 1: - mask = mask.squeeze(-1) - - if mask.dim() != 3: - raise ValueError("GrowBlurMaskMXD: mask must have shape (H,W) or (B,H,W).") - - return mask.float().clamp(0.0, 1.0) - - def _dilate(self, mask: torch.Tensor, radius: int) -> torch.Tensor: - if radius <= 0: - return mask - x = mask.unsqueeze(1) - k = 2 * radius + 1 - y = F.max_pool2d(x, kernel_size=k, stride=1, padding=radius) - return y.squeeze(1) - - def _erode(self, mask: torch.Tensor, radius: int) -> torch.Tensor: - if radius <= 0: - return mask - x = mask.unsqueeze(1) - k = 2 * radius + 1 - y = 1.0 - F.max_pool2d(1.0 - x, kernel_size=k, stride=1, padding=radius) - return y.squeeze(1) - - def _blur_ring(self, ring: torch.Tensor, radius: int, device: torch.device) -> torch.Tensor: - if radius <= 0: - return ring - ring_cpu = ring.detach().cpu().numpy() - blurred = [] - for i in range(ring_cpu.shape[0]): - arr = (ring_cpu[i] * 255.0).astype(np.uint8) - pil = Image.fromarray(arr, mode="L") - pil = pil.filter(ImageFilter.GaussianBlur(radius)) - out = np.array(pil).astype(np.float32) / 255.0 - blurred.append(torch.from_numpy(out)) - blurred_t = torch.stack(blurred, dim=0).to(device) - return blurred_t.clamp(0.0, 1.0) - - def run(self, mask, grow_blur): - mask = self._normalize_mask(mask) - device = mask.device - - if grow_blur == 0: - return (mask,) - - radius = abs(int(grow_blur)) - - if grow_blur < 0: - eroded = self._erode(mask, radius) - return (eroded.clamp(0.0, 1.0),) - - core = mask - expanded = self._dilate(mask, radius) - ring = (expanded - core).clamp(0.0, 1.0) - blurred_ring = self._blur_ring(ring, radius, device) - blurred_ring = (blurred_ring * expanded).clamp(0.0, 1.0) - out = (core + blurred_ring).clamp(0.0, 1.0) - return (out,) - -######################################################################################################################## - # Get Latent Size class GetLatentSizeMXD: DESCRIPTION = """Get image width/height from a latent.""" @@ -1472,7 +1387,6 @@ NODE_CLASS_MAPPINGS = { "FluxResolutionMatcher": FluxResolutionMatcher, "SDXLResolutionMatcher": SDXLResolutionMatcher, "LatentHalfMasks": LatentHalfMasks, - "Grow Blur Mask MXD": GrowBlurMaskMXD, "Get Latent Size": GetLatentSizeMXD, "Place Image By Mask": PlaceImageByMask, "Crop Image By Mask": CropImageByMask, @@ -1500,7 +1414,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FluxResolutionMatcher": "Flux Resolution Matcher MXD", "SDXLResolutionMatcher": "SDXL Resolution Matcher MXD", "LatentHalfMasks": "Latent to L/R Masks MXD", - "Grow Blur Mask MXD": "Grow Blur Mask MXD", "Get Latent Size": "Get Latent Size MXD", "Place Image By Mask": "Place Image by Mask MXD", "Crop Image By Mask": "Crop Image by Mask MXD", diff --git a/pyproject.toml b/pyproject.toml index 8dc267d..ee56140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI-MaxedOut" description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)" -version = "1.8.9" +version = "1.9.0" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) @@ -33,5 +33,3 @@ DisplayName = "Maxed Out" Icon = "https://github.com/Maxed-Out-99/ComfyUI-MaxedOut/blob/main/assets/maxedout_icon.png?raw=true" includes = [] # "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility - - diff --git a/web/index.js b/web/index.js index ffd3399..82f14a2 100644 --- a/web/index.js +++ b/web/index.js @@ -2,4 +2,5 @@ import './js/betterCombos.js'; import './js/image_comparer.js'; import './addons/zip_loader/js/zip_loader.js'; import './loraloader_mxd_entry.js'; +import './mxd_character_prompts.js'; diff --git a/web/mxd_character_prompts.js b/web/mxd_character_prompts.js new file mode 100644 index 0000000..9854711 --- /dev/null +++ b/web/mxd_character_prompts.js @@ -0,0 +1,176 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const TARGET_NODE_CLASS = "MXDCharacterEditDelete"; +const TARGET_PROMPT_ENCODE_CLASS = "MXDCharacterPromptEncode"; +const TARGET_WIDGET_NAME = "selected_character"; +const NO_CHARACTERS_OPTION = "(no characters saved)"; +const PICK_CHARACTER_OPTION = "(pick character)"; + +async function fetchCharacterNames() { + try { + const response = await api.fetchApi("/mxd/characters/list"); + if (!response.ok) { + return []; + } + const data = await response.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +function setComboOptions(widget, values) { + const options = values.length ? values : [NO_CHARACTERS_OPTION]; + widget.options = widget.options || {}; + widget.options.values = options; + + if (!options.includes(widget.value)) { + widget.value = options[0]; + } +} + +function moveWidgetAfter(node, widgetToMove, referenceWidget) { + const widgets = node.widgets || []; + const moveIndex = widgets.indexOf(widgetToMove); + const refIndex = widgets.indexOf(referenceWidget); + if (moveIndex === -1 || refIndex === -1) { + return; + } + + widgets.splice(moveIndex, 1); + + const nextRefIndex = widgets.indexOf(referenceWidget); + widgets.splice(nextRefIndex + 1, 0, widgetToMove); +} + +function moveWidgetBefore(node, widgetToMove, referenceWidget) { + const widgets = node.widgets || []; + const moveIndex = widgets.indexOf(widgetToMove); + const refIndex = widgets.indexOf(referenceWidget); + if (moveIndex === -1 || refIndex === -1) { + return; + } + widgets.splice(moveIndex, 1); + const nextRefIndex = widgets.indexOf(referenceWidget); + widgets.splice(nextRefIndex, 0, widgetToMove); +} + +function installRefreshButton(node) { + if (node.__mxdCharacterRefreshInstalled) { + return; + } + const selectedWidget = (node.widgets || []).find((widget) => widget.name === TARGET_WIDGET_NAME); + if (!selectedWidget) { + return; + } + node.__mxdCharacterRefreshInstalled = true; + + const refresh = async () => { + const names = await fetchCharacterNames(); + setComboOptions(selectedWidget, names); + app.graph.setDirtyCanvas(true, true); + }; + + const refreshWidget = node.addWidget("button", "update list", null, refresh, {}); + refreshWidget.serialize = false; + moveWidgetAfter(node, refreshWidget, selectedWidget); + + refresh(); +} + +function appendCharacterToPrompt(node, characterName) { + if (!characterName || characterName === PICK_CHARACTER_OPTION || characterName === NO_CHARACTERS_OPTION) { + return; + } + + const promptWidget = (node.widgets || []).find((widget) => widget.name === "prompt"); + if (!promptWidget) { + return; + } + + const currentPrompt = typeof promptWidget.value === "string" ? promptWidget.value : ""; + const needsLeadingSpace = currentPrompt.length > 0 && !/\s$/.test(currentPrompt); + const prefix = needsLeadingSpace ? " " : ""; + const suffix = /\s$/.test(characterName) ? "" : " "; + promptWidget.value = `${currentPrompt}${prefix}${characterName}${suffix}`; + app.graph.setDirtyCanvas(true, true); +} + +function installPromptCharacterPicker(node) { + if (node.__mxdCharacterPickerInstalled) { + return; + } + + const promptWidget = (node.widgets || []).find((widget) => widget.name === "prompt"); + if (!promptWidget) { + return; + } + node.__mxdCharacterPickerInstalled = true; + + const pickerWidget = node.addWidget( + "combo", + "character_list", + PICK_CHARACTER_OPTION, + (value) => { + appendCharacterToPrompt(node, value); + pickerWidget.value = PICK_CHARACTER_OPTION; + app.graph.setDirtyCanvas(true, true); + }, + { values: [PICK_CHARACTER_OPTION] } + ); + pickerWidget.serialize = false; + + const refreshPicker = async () => { + const names = await fetchCharacterNames(); + const options = names.length ? [PICK_CHARACTER_OPTION, ...names] : [PICK_CHARACTER_OPTION, NO_CHARACTERS_OPTION]; + pickerWidget.options = pickerWidget.options || {}; + pickerWidget.options.values = options; + if (!options.includes(pickerWidget.value)) { + pickerWidget.value = PICK_CHARACTER_OPTION; + } + app.graph.setDirtyCanvas(true, true); + }; + + const refreshWidget = node.addWidget("button", "update list", null, refreshPicker, {}); + refreshWidget.serialize = false; + + const handlingWidget = (node.widgets || []).find((widget) => widget.name === "character_handling"); + if (handlingWidget) { + moveWidgetBefore(node, pickerWidget, handlingWidget); + moveWidgetBefore(node, refreshWidget, handlingWidget); + } + + refreshPicker(); +} + +app.registerExtension({ + name: "mxd.character_prompts.refresh", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== TARGET_NODE_CLASS && nodeData.name !== TARGET_PROMPT_ENCODE_CLASS) { + return; + } + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + if (nodeData.name === TARGET_NODE_CLASS) { + installRefreshButton(this); + } else if (nodeData.name === TARGET_PROMPT_ENCODE_CLASS) { + installPromptCharacterPicker(this); + } + return result; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = onConfigure?.apply(this, arguments); + if (nodeData.name === TARGET_NODE_CLASS) { + installRefreshButton(this); + } else if (nodeData.name === TARGET_PROMPT_ENCODE_CLASS) { + installPromptCharacterPicker(this); + } + return result; + }; + }, +});