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.
This commit is contained in:
@@ -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:
|
||||
|
||||
`<ComfyUI user>/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.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"(?<!\w)@", suffix=r"(?![\w\-])")
|
||||
plain_pattern = _pattern_from_terms(sorted_terms, prefix=r"(?<![@\w])", suffix=r"(?![\w\-])")
|
||||
|
||||
if reference_mode in ("support_both", "tags_only"):
|
||||
text, next_id = _replace_with_placeholders(
|
||||
text=text,
|
||||
pattern=tag_pattern,
|
||||
lookup=lookup,
|
||||
characters=characters,
|
||||
used=used,
|
||||
placeholders=placeholders,
|
||||
next_id_start=next_id,
|
||||
)
|
||||
|
||||
if reference_mode in ("support_both", "plain_only"):
|
||||
text, next_id = _replace_with_placeholders(
|
||||
text=text,
|
||||
pattern=plain_pattern,
|
||||
lookup=lookup,
|
||||
characters=characters,
|
||||
used=used,
|
||||
placeholders=placeholders,
|
||||
next_id_start=next_id,
|
||||
)
|
||||
|
||||
for placeholder, replacement in placeholders.items():
|
||||
text = text.replace(placeholder, replacement)
|
||||
|
||||
missing = _find_missing_tags(prompt, lookup) if reference_mode in ("support_both", "tags_only") else []
|
||||
_handle_missing_tags(missing, missing_behavior)
|
||||
|
||||
return ResolveResult(
|
||||
resolved_prompt=text,
|
||||
scene_prompt=_scene_prompt_from_original(prompt),
|
||||
used_canonical_names=used,
|
||||
missing_tags=missing,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import folder_paths
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EXTENSION_DIRNAME = "ComfyUI-CharacterPrompts-MXD"
|
||||
FILENAME = "characters.json"
|
||||
|
||||
|
||||
def normalize_key(value: str) -> 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
|
||||
+8
-2
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user