Files
IsulionandClaude Opus 5.5 96553dee1c feat: v6.1.0 — Censored Part & Prompt Vault nodes, selector RNG, hardening
New nodes:
- Isulion Censored Part: blur/pixelate/black bar/noise over a mask and/or
  a percent rectangle; accepts 2D/3D/4D masks and fails closed (blacks the
  item out) instead of passing an uncensored image through
- Prompt Vault Save/Load: named prompt library stored in ComfyUI's user
  directory (outside the checkout, so updates never conflict), seeded from
  prompt_vault.example.json; Load re-runs when the vault changes

Refactor:
- SimpleSelectorNode base: ~20 selector nodes use an instance RNG instead
  of reseeding the global random module (same seed => same output)
- Theme handlers declare DISPLAY_NAME; theme package auto-discovers modules
- __init__ imports node classes directly

Fixes:
- net_utils: re-validate every redirect hop, unwrap IPv4-mapped IPv6,
  block NAT64 and all non-global addresses (SSRF)
- Multiple Prompt Generator: no duplicate prompts for themes listed in two
  categories; 6 themes missing from every category are now reachable
- Shutdown node: parameter renamed to match its "any" input (TypeError)
- ConfigManager: deterministic (sorted) merge order, warns on key
  collisions; drop stale space_colony copy from tropical_paradise_config
- Mega Prompt V3: seed honored again with randomize=disable

Breaking: Civitai api_key widget removed (use CIVITAI_API_TOKEN), absolute
paths refused by Load Images, Negative Prompt pools de-duplicated (shorter
"strict" output), Dynamic Random theme order is now alphabetical.

Tooling: pytest suite (51 tests), GitHub CI (compileall, pytest, ruff),
ruff config, uv.lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:35:56 +02:00

189 lines
6.6 KiB
Python

"""Prompt Vault nodes: a shared JSON-backed library of named prompt fragments.
Two nodes share one vault file. By default it lives in ComfyUI's user
directory (user/isulion/prompt_vault.json), outside this git checkout, so
saving entries never blocks `git pull` / ComfyUI-Manager updates. Until the
first save, the bundled prompt_vault.example.json entries are offered.
- IsulionPromptVaultSave: stores/updates an entry under a name and passes the
text through unchanged, so it can be chained straight into KSampler.
- IsulionPromptVaultLoad: exposes every stored entry as a dropdown.
The vault path can be overridden with the ISULION_PROMPT_VAULT environment
variable. The variable is read on every call (no caching) so tests and
runtime overrides both work without re-importing.
"""
import json
import logging
import os
import tempfile
import threading
logger = logging.getLogger(__name__)
MAX_NAME_LENGTH = 64
_LOCK = threading.Lock()
_EXAMPLE_VAULT = os.path.join(os.path.dirname(__file__), "prompt_vault.example.json")
def _default_vault_path():
try:
import folder_paths
user_dir = folder_paths.get_user_directory()
except (ImportError, AttributeError):
user_dir = None
if not isinstance(user_dir, str):
# Outside ComfyUI: fall back to a gitignored folder inside the package.
user_dir = os.path.join(os.path.dirname(__file__), "user_data")
return os.path.join(user_dir, "isulion", "prompt_vault.json")
def _vault_path():
override = os.environ.get("ISULION_PROMPT_VAULT")
return override if override else _default_vault_path()
def _empty_vault():
return {"version": 1, "entries": []}
def _read_vault(path):
"""Return the parsed vault; missing/corrupt files degrade to an empty vault.
A missing *default* vault is seeded from the bundled example entries.
"""
if not os.path.exists(path):
if path == _default_vault_path() and os.path.exists(_EXAMPLE_VAULT):
return _read_vault(_EXAMPLE_VAULT)
return _empty_vault()
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError) as exc:
logger.warning("PromptVault - could not read %s (%s); treating as empty", path, exc)
return _empty_vault()
if not isinstance(data, dict):
logger.warning("PromptVault - vault root is not an object in %s; treating as empty", path)
return _empty_vault()
entries = data.get("entries")
if not isinstance(entries, list):
logger.warning("PromptVault - 'entries' missing or not a list in %s; treating as empty", path)
return {"version": data.get("version", 1), "entries": []}
clean = [
entry for entry in entries
if isinstance(entry, dict) and isinstance(entry.get("name"), str) and isinstance(entry.get("text"), str)
]
return {"version": data.get("version", 1), "entries": clean}
def _write_vault(path, data):
"""Atomically write the vault (temp file + os.replace)."""
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".prompt_vault_", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, ensure_ascii=False)
handle.write("\n")
# mkstemp creates 0600 files; keep the vault readable like a normal file.
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
def _sanitize_name(raw):
"""Collapse whitespace runs and cap the length so names stay dropdown-friendly."""
return " ".join(str(raw).split())[:MAX_NAME_LENGTH].strip()
def vault_names(path=None):
"""Names of all vault entries, in stored order."""
return [entry["name"] for entry in _read_vault(path or _vault_path())["entries"]]
class IsulionPromptVaultSave:
"""Store a prompt fragment in the vault under a name (upsert)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": ("STRING", {"default": "", "placeholder": "entry name"}),
"text": ("STRING", {"default": "", "multiline": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "save"
CATEGORY = "Isulion/Utilities"
def save(self, name, text):
clean_name = _sanitize_name(name)
if not clean_name:
raise ValueError("PromptVault - 'name' is empty; give the entry a name before saving.")
with _LOCK:
path = _vault_path()
vault = _read_vault(path)
entries = vault["entries"]
for index, entry in enumerate(entries):
if entry["name"] == clean_name:
entries[index] = {"name": clean_name, "text": text}
break
else:
entries.append({"name": clean_name, "text": text})
_write_vault(path, {"version": 1, "entries": entries})
logger.info("PromptVault - saved entry '%s' (%d chars)", clean_name, len(text))
return (text,)
class IsulionPromptVaultLoad:
"""Fetch a stored prompt fragment by name."""
@classmethod
def INPUT_TYPES(cls):
names = vault_names() or [""]
return {
"required": {
# allowCustomValue: the combo is built at page load, but users can
# type any name directly (e.g. one saved moments ago) without a refresh.
"entry": (names, {"default": names[0], "allowCustomValue": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "load"
CATEGORY = "Isulion/Utilities"
@classmethod
def IS_CHANGED(cls, entry):
# Re-run whenever the vault file changes, otherwise ComfyUI's cache
# would keep returning the text from before the last save.
try:
return os.stat(_vault_path()).st_mtime_ns
except OSError:
return float("nan")
@classmethod
def VALIDATE_INPUTS(cls, entry):
# Accept names that are not in the dropdown yet (e.g. saved earlier in
# the same run); load() reports unknown names at execution time.
return True
def load(self, entry):
entries = _read_vault(_vault_path())["entries"]
for item in entries:
if item["name"] == entry:
return (item["text"],)
available = ", ".join(item["name"] for item in entries) or "(none)"
raise ValueError(f"PromptVault - unknown entry '{entry}'. Available: {available}")