Files
olivv-cs-ComfyUI-FunPack/templates.py
T
digital-garbageandClaude Opus 5 8d4ed22853 maint: remove template helpers orphaned by the payload removal
json_safe and maybe_store_string were reachable only through
collect_template_payload (removed in 055b256). Caught by re-running the
orphan scan after that commit rather than assuming the first pass was final.

Checked: 1 occurrence each repo-wide (grep -a), 0 refs in ComfyUI core/FunUI.
Tests: 22 failed / 460 passed, identical to baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:17:36 +03:00

1467 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import copy
import json
import os
import random
import re
from hashlib import md5
from datetime import datetime, timezone
import folder_paths
import torch
from aiohttp import web
from server import PromptServer
try:
from .conditioning import (
normalize_refiner_rating,
refinement_state_path,
serializable_to_tensor,
tensor_to_serializable,
)
except ImportError:
from conditioning import (
normalize_refiner_rating,
refinement_state_path,
serializable_to_tensor,
tensor_to_serializable,
)
TEMPLATE_NONE = "-None-"
REFINEMENT_KEY_NONE = "-None-"
SCENE_NONE = "-None-"
TEMPLATE_DB_VERSION = 1
SCENE_CATEGORIES = {
"negative": {"bad", "blurry", "worst", "low", "noise", "deformed", "artifact", "ugly", "broken"},
"action": {"walk", "walking", "run", "running", "turn", "turning", "dance", "dancing", "jump", "jumping", "move", "moving", "motion", "hold", "holding", "look", "looking", "smile", "smiling"},
"camera": {"camera", "shot", "closeup", "close-up", "wide", "angle", "zoom", "pan", "dolly", "tracking", "handheld", "focus", "framing", "lens", "viewpoint"},
"subject": {"woman", "man", "girl", "boy", "person", "character", "robot", "creature", "dragon", "animal", "vehicle", "object", "monster"},
"appearance": {"hair", "eyes", "face", "skin", "dress", "jacket", "armor", "outfit", "clothing", "pose", "expression", "body", "wearing", "shirt", "coat", "robe", "boots", "hat", "mask"},
"environment": {"forest", "city", "street", "room", "beach", "mountain", "temple", "sunset", "night", "rain", "snow", "sky", "background", "setting", "landscape", "interior", "exterior"},
"style": {"anime", "cinematic", "photorealistic", "painterly", "illustration", "stylized", "realistic", "film", "noir", "dramatic", "soft", "lighting", "moody", "neon", "gothic"},
"quality": {"masterpiece", "best", "quality", "detailed", "sharp", "highres", "high-res", "ultra", "perfect", "clean", "realism", "smooth", "crisp", "polished", "4k", "8k"},
"details": {"reflection", "reflections", "texture", "textures", "shadow", "shadows", "smoke", "dust", "particles", "prop", "props", "fabric", "glass", "sparkles", "pattern", "grain"},
}
SCENE_STOP_WORDS = {
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in",
"into", "is", "it", "its", "of", "on", "or", "the", "their", "then",
"there", "through", "to", "with", "without",
}
def template_store_dir():
user_dir_getter = getattr(folder_paths, "get_user_directory", None)
if callable(user_dir_getter):
base_dir = user_dir_getter()
else:
base_dir = getattr(folder_paths, "user_directory", None)
if not base_dir:
base_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_dir, "default", "FunPack")
def template_store_path():
return os.path.join(template_store_dir(), "templates.json")
def shortcut_store_path():
return os.path.join(template_store_dir(), "shortcuts.json")
def transition_store_path():
"""On-disk store for custom prompt split markers (formerly transitions.json)."""
return os.path.join(template_store_dir(), "promptsplit.json")
def _legacy_transition_store_path():
return os.path.join(template_store_dir(), "transitions.json")
def _migrate_transition_store_if_needed() -> None:
path = transition_store_path()
legacy = _legacy_transition_store_path()
if os.path.exists(path) or not os.path.exists(legacy):
return
try:
os.rename(legacy, path)
except OSError:
import shutil
shutil.copy2(legacy, path)
def refinement_store_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "refinements")
def normalize_refinement_key(value):
value = str(value or "").strip()
# Keys are stored readably as <key>.json now, so a user (or a dropped filename)
# that includes the extension still resolves to the same key.
if value.lower().endswith(".json"):
value = value[:-5].strip()
if not value or value == REFINEMENT_KEY_NONE:
return ""
return value
def empty_v2_refinement_state(refinement_key):
return {
"version": 2,
"refinement_key": refinement_key,
"state_namespace": "clip",
"global": {
"total_iterations": 0,
"avg_reward_ema": 0.0,
"good_streak": 0,
"bad_streak": 0,
"last_rating_label": "Initial discovery",
"last_missing_axes": [],
"phrase_memory": {},
"axis_conditioning_memory": {},
"lora_weight_memory": {},
"preferred_context_memory": {},
"loss_history": [],
},
"prompt_histories": {},
"last_run": None,
}
def refinement_key_path(refinement_key):
return refinement_state_path(refinement_key, "clip", prefix="refine_v2")
def normalize_refinement_state(data, fallback_key=""):
if not isinstance(data, dict):
return None, ""
key = normalize_refinement_key(data.get("refinement_key") or fallback_key)
if not key:
return None, ""
state = dict(data)
state["version"] = 2
state["refinement_key"] = key
state["state_namespace"] = "clip"
state.setdefault("global", {})
state["global"].setdefault("phrase_memory", {})
state["global"].setdefault("axis_conditioning_memory", {})
state["global"].setdefault("lora_weight_memory", {})
state["global"].setdefault("preferred_context_memory", {})
state["global"].setdefault("loss_history", [])
state.setdefault("prompt_histories", {})
state.setdefault("last_run", None)
return state, key
def _coerce_refinement_payload(data):
"""Unwrap common export envelopes so any shared / HuggingFace refinement file
resolves to its inner V2 clip state. The Studio export and the Editor import
both round-trip through ``{"state": {...}}`` wrappers in some versions."""
if isinstance(data, dict) and isinstance(data.get("state"), dict):
data = data["state"]
return data if isinstance(data, dict) else None
# A refinement key is stored as <key>.json. Sidecars in the same folder are either
# non-.json (value_fn .pt, latents .pt) or carry a mode suffix (<key>.sampler_ctx.json),
# and legacy installs may still hold opaque <prefix>_<md5>.json files. All are
# excluded from the key listing below.
_SIDECAR_JSON_SUFFIXES = (".sampler_ctx.json",)
_LEGACY_HASHED_RE = re.compile(r"_[0-9a-f]{32}$|^[0-9a-f]{32}$")
def load_refinement_key_state(refinement_key, create=False):
key = normalize_refinement_key(refinement_key)
if not key:
return None, "", "No refinement key selected."
path = refinement_key_path(key)
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
state, loaded_key = normalize_refinement_state(data, key)
if state is None:
return None, key, f"Refinement key '{key}' is unreadable."
return state, loaded_key, f"Loaded refinement key '{loaded_key}'."
except (json.JSONDecodeError, OSError, ValueError):
return None, key, f"Refinement key '{key}' is unreadable."
if not create:
return None, key, f"Refinement key '{key}' does not exist."
state = empty_v2_refinement_state(key)
save_refinement_key_state(state, key)
return state, key, f"Created refinement key '{key}'."
def save_refinement_key_state(state, refinement_key):
key = normalize_refinement_key(refinement_key)
if not key:
return ""
state, key = normalize_refinement_state(state, key)
if state is None:
return ""
path = refinement_key_path(key)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as file:
json.dump(state, file, indent=2)
return path
def refinement_key_names():
# The key IS the filename: every <key>.json in the folder is a key. Skip mode
# sidecars, legacy hashed files, and internal dunder keys (e.g. the Absolute
# taste store).
keys = set()
directory = refinement_store_dir()
if os.path.isdir(directory):
for filename in os.listdir(directory):
low = filename.lower()
if not low.endswith(".json") or low.endswith(_SIDECAR_JSON_SUFFIXES):
continue
stem = filename[:-5]
if stem.startswith("__") or _LEGACY_HASHED_RE.search(stem):
continue
if not os.path.isfile(os.path.join(directory, filename)):
continue
key = normalize_refinement_key(stem)
if key:
keys.add(key)
return [REFINEMENT_KEY_NONE] + sorted(keys)
def _refinement_key_files(key):
"""Every top-level store file that makes up one refinement key: the canonical
<key>.json plus its sidecars (<key>.value_fn.pt, <key>.sampler_ctx.json, and any
other <key>.<mode>.* file). The attn_maps/ banks + velocity store are handled
separately via clear_refinement_data. Used by delete_refinement_key so a key is
removed atomically instead of leaving sidecars orphaned."""
paths = set()
# Known canonical + sidecar modes (deterministic paths).
paths.add(refinement_state_path(key, "clip", prefix="refine_v2"))
paths.add(refinement_state_path(key, "value_fn", prefix="refine_v2", extension="pt"))
paths.add(refinement_state_path(key, "sampler_ctx", prefix="refine_v2"))
# Defensive: sweep any other top-level <key>.* sidecar so a future sidecar type
# can't be orphaned — that orphaning is the whole bug this delete path fixes.
directory = refinement_store_dir()
if os.path.isdir(directory):
try:
clip_name = os.path.basename(refinement_state_path(key, "clip", prefix="refine_v2"))
stem = clip_name[:-5] if clip_name.lower().endswith(".json") else clip_name
for filename in os.listdir(directory):
if filename == clip_name or filename.startswith(stem + "."):
full = os.path.join(directory, filename)
if os.path.isfile(full):
paths.add(full)
except OSError:
pass
return paths
def delete_refinement_key(refinement_key):
"""Atomically remove EVERY file that makes up a refinement key — the canonical
<key>.json AND its sidecars (value function, sampler context, blessed attention
maps / K/V identity banks, creativity latent, velocity-bias store) — plus the
in-process velocity memory.
Deleting only <key>.json (e.g. by hand in the folder) used to orphan the value
function and blessed banks; those keep steering future generations and survive a
ComfyUI restart, which is the root of 'I cleared the key but generations stayed
dirty'. This makes deletion match the user's mental model: key gone = gone."""
key = normalize_refinement_key(refinement_key)
if not key:
return {"deleted": "", "removed": 0, "error": "No refinement key selected."}
removed = 0
for path in _refinement_key_files(key):
try:
if os.path.isfile(path):
os.remove(path)
removed += 1
except OSError as exc:
print(f"[FunPack] delete key '{key}': could not remove {path}: {exc}")
# attn_maps banks (blessed/temp maps, attn weights, K/V) + creativity latent +
# the on-disk AND in-process velocity-bias memory.
try:
try:
from .ltx_enhancements import clear_refinement_data
except ImportError:
from ltx_enhancements import clear_refinement_data
clear_refinement_data(key)
except Exception as exc:
print(f"[FunPack] delete key '{key}': enhancement cleanup failed: {exc}")
return {"deleted": key, "removed": removed}
def _absolute_store_paths():
try:
from .conditioning import FUNPACK_ABSOLUTE_KEY
except ImportError:
from conditioning import FUNPACK_ABSOLUTE_KEY
return (
refinement_state_path(FUNPACK_ABSOLUTE_KEY, "clip", prefix="refine_v2"),
refinement_state_path(FUNPACK_ABSOLUTE_KEY, "value_fn", prefix="refine_v2", extension="pt"),
)
def absolute_store_info():
"""Surface the keyless Absolute 'global taste' store — it learns from every rated
generation across all prompts, is invisible in the key list (dunder name), and is
only otherwise wiped by Session Reset. Returned so the UI can show + clear it."""
json_path, vf_path = _absolute_store_paths()
info = {"exists": os.path.isfile(json_path) or os.path.isfile(vf_path),
"total_iterations": 0, "liked_count": 0, "bad_count": 0}
if os.path.isfile(json_path):
try:
with open(json_path, "r", encoding="utf-8") as file:
data = json.load(file)
g = data.get("global", data) if isinstance(data, dict) else {}
info["total_iterations"] = int(g.get("total_iterations", 0) or 0)
info["liked_count"] = int((g.get("liked_dir") or {}).get("direction_count", 0) or 0)
info["bad_count"] = int((g.get("bad_dir") or {}).get("direction_count", 0) or 0)
except (json.JSONDecodeError, OSError, ValueError):
pass
return info
def clear_absolute_store():
"""Wipe the keyless Absolute taste store (direction pool + global value function)."""
removed = 0
for path in _absolute_store_paths():
try:
if os.path.isfile(path):
os.remove(path)
removed += 1
except OSError as exc:
print(f"[FunPack] clear absolute store: could not remove {path}: {exc}")
return {"cleared": True, "removed": removed}
def empty_template_db():
return {
"version": TEMPLATE_DB_VERSION,
"source": "ComfyUI-FunPack",
"templates": {},
}
def load_template_db():
path = template_store_path()
if not os.path.exists(path):
return empty_template_db()
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
return empty_template_db()
if not isinstance(data, dict):
return empty_template_db()
templates = data.get("templates")
if not isinstance(templates, dict):
data["templates"] = {}
data.setdefault("version", TEMPLATE_DB_VERSION)
data.setdefault("source", "ComfyUI-FunPack")
return data
def save_template_db(data):
os.makedirs(template_store_dir(), exist_ok=True)
with open(template_store_path(), "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, sort_keys=True)
def template_names():
data = load_template_db()
names = sorted(
name for name in data.get("templates", {}).keys()
if isinstance(name, str) and name.strip()
)
return [TEMPLATE_NONE] + names
def empty_shortcut_db():
return {
"version": 1,
"source": "ComfyUI-FunPack",
"shortcuts": {},
# Managed grouping list for the Composer. Each entry is
# {"name": <category>, "sub_categories": [<sub>, ...]}. Persisted so a
# category survives even with no shortcut assigned to it yet.
"categories": [],
}
def _clean_label(value):
return re.sub(r"\s+", " ", str(value or "").strip())
def normalize_category_list(value):
"""List of {"name", "sub_categories": [...]} — deduped (case-insensitive),
order-preserving, sub-categories nested under their parent category."""
if not isinstance(value, list):
return []
out = []
index = {} # lower(name) -> position in out
for entry in value:
if isinstance(entry, str):
entry = {"name": entry, "sub_categories": []}
if not isinstance(entry, dict):
continue
name = _clean_label(entry.get("name"))
if not name:
continue
key = name.lower()
if key not in index:
index[key] = len(out)
out.append({"name": name, "sub_categories": []})
bucket = out[index[key]]["sub_categories"]
seen = {s.lower() for s in bucket}
for sub in entry.get("sub_categories", []) or []:
sub = _clean_label(sub)
if sub and sub.lower() not in seen:
seen.add(sub.lower())
bucket.append(sub)
return out
def _union_category(categories, name, sub=""):
"""Ensure `name` (and optional `sub`) exists in the managed list. Mutates + returns."""
name = _clean_label(name)
if not name:
return categories
key = name.lower()
entry = next((c for c in categories if c["name"].lower() == key), None)
if entry is None:
entry = {"name": name, "sub_categories": []}
categories.append(entry)
sub = _clean_label(sub)
if sub and sub.lower() not in {s.lower() for s in entry["sub_categories"]}:
entry["sub_categories"].append(sub)
return categories
def shortcut_key(value):
value = re.sub(r"[^\w'’.-]+", " ", str(value or "").strip().lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", value).strip()
def shortcut_list(value):
if isinstance(value, str):
try:
parsed = json.loads(value)
value = parsed
except (json.JSONDecodeError, TypeError, ValueError):
value = re.split(r"[,;\n]+", value)
if not isinstance(value, list):
return []
result = []
seen = set()
for item in value:
text = re.sub(r"\s+", " ", str(item or "").strip())
key = shortcut_key(text)
if text and key and key not in seen:
seen.add(key)
result.append(text)
return result
def normalize_shortcut_name(value, fallback=""):
value = re.sub(r"\s+", " ", str(value or "").strip())
if value:
return value
fallback = re.sub(r"\s+", " ", str(fallback or "").strip())
return fallback
def normalize_shortcut_item(item, fallback_name=""):
if not isinstance(item, dict):
return None
name = normalize_shortcut_name(item.get("name"), fallback_name)
triggers = shortcut_list(item.get("triggers", item.get("activation_words", item.get("activation", []))))
replacements = _shortcut_replacements(item.get("replacements", item.get("replacement", []))) or [""]
if not name:
name = triggers[0] if triggers else ""
if not name or not triggers:
return None
created = str(item.get("created_at") or now_iso())
return {
"name": name,
"enabled": bool(item.get("enabled", True)),
"triggers": triggers,
"replacements": replacements,
# Optional per-shortcut refinement key. Empty string = bound to the run's default
# key (no special handling). A non-default key means "when this shortcut fires,
# this key is being trained" — Studio steers/rates the scene against it.
"refinement_key": normalize_refinement_key(item.get("refinement_key", "")),
# Optional grouping for the Composer (free-text). Sub-category is only
# meaningful under a category, but we store both verbatim.
"category": re.sub(r"\s+", " ", str(item.get("category") or "").strip()),
"sub_category": re.sub(r"\s+", " ", str(item.get("sub_category") or "").strip()),
"created_at": created,
"updated_at": str(item.get("updated_at") or now_iso()),
}
def normalize_shortcut_db(data):
if not isinstance(data, dict):
data = empty_shortcut_db()
shortcuts = data.get("shortcuts", {})
if isinstance(shortcuts, list):
shortcuts = {str(index): item for index, item in enumerate(shortcuts)}
if not isinstance(shortcuts, dict):
shortcuts = {}
normalized = {}
for fallback_name, item in shortcuts.items():
shortcut = normalize_shortcut_item(item, fallback_name)
if not shortcut:
continue
key = shortcut_key(shortcut["name"]) or shortcut_key(fallback_name)
if key:
normalized[key] = shortcut
data["version"] = 1
data["source"] = "ComfyUI-FunPack"
data["shortcuts"] = normalized
# Managed category list: normalize, then union any category/sub-category a
# shortcut references so the list never loses a grouping that's in use.
categories = normalize_category_list(data.get("categories"))
for shortcut in normalized.values():
_union_category(categories, shortcut.get("category"), shortcut.get("sub_category"))
data["categories"] = categories
return data
def load_shortcut_db():
path = shortcut_store_path()
if not os.path.exists(path):
return empty_shortcut_db()
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
return empty_shortcut_db()
return normalize_shortcut_db(data)
def save_shortcut_db(data):
data = normalize_shortcut_db(data)
os.makedirs(template_store_dir(), exist_ok=True)
with open(shortcut_store_path(), "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, sort_keys=True)
REVOLVER_STORE_FILENAME = "shortcut_revolver.json"
def revolver_store_path():
return os.path.join(template_store_dir(), REVOLVER_STORE_FILENAME)
def load_revolver():
"""Shortcut-revolver settings + per-shortcut cycle state. Sidecar of the shortcut DB —
deliberately NOT part of the shortcuts export/import payload. ``state[<shortcut key>]`` =
{"fp": <fingerprint of the replacement list>, "queue": [remaining replacement indices,
next one first]}."""
data = {}
path = revolver_store_path()
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
data = {}
if not isinstance(data, dict):
data = {}
state = data.get("state")
return {
"enabled": bool(data.get("enabled", False)),
"random": bool(data.get("random", False)),
"state": state if isinstance(state, dict) else {},
}
def save_revolver(data):
os.makedirs(template_store_dir(), exist_ok=True)
with open(revolver_store_path(), "w", encoding="utf-8") as file:
json.dump({
"enabled": bool(data.get("enabled", False)),
"random": bool(data.get("random", False)),
"state": data.get("state") if isinstance(data.get("state"), dict) else {},
}, file, indent=2)
def revolver_enabled():
try:
return bool(load_revolver()["enabled"])
except Exception:
return False
def revolver_settings():
data = load_revolver()
return {"enabled": data["enabled"], "random": data["random"]}
def set_revolver_settings(enabled=None, random_order=None):
"""Update revolver settings. Any actual change resets the cycle state — the old queues'
ordering semantics no longer apply once the mode flips."""
data = load_revolver()
changed = False
if enabled is not None and bool(enabled) != data["enabled"]:
data["enabled"] = bool(enabled)
changed = True
if random_order is not None and bool(random_order) != data["random"]:
data["random"] = bool(random_order)
changed = True
if changed:
data["state"] = {}
save_revolver(data)
return {"enabled": data["enabled"], "random": data["random"]}
def _revolver_fingerprint(replacements):
payload = json.dumps(list(replacements), ensure_ascii=False)
return md5(payload.encode("utf-8")).hexdigest()[:12]
def revolver_next_replacement(state, key, replacements, random_order):
"""Draw the next replacement for shortcut ``key`` from its revolver queue, mutating
``state`` in place. The queue is rebuilt — a fresh full cycle over every replacement —
when it's empty or the replacement set changed (fingerprint mismatch): sequential
(first, second, … last) by default, shuffled once per cycle when ``random_order``.
Either way nothing repeats until the whole set has fired."""
n = len(replacements)
fp = _revolver_fingerprint(replacements)
entry = state.get(key)
queue = None
if isinstance(entry, dict) and entry.get("fp") == fp and isinstance(entry.get("queue"), list):
queue = [int(i) for i in entry["queue"] if isinstance(i, int) and 0 <= int(i) < n]
if not queue:
queue = list(range(n))
if random_order:
random.shuffle(queue)
index = queue.pop(0)
state[key] = {"fp": fp, "queue": queue}
return replacements[index]
def shortcut_items(data=None):
data = load_shortcut_db() if data is None else normalize_shortcut_db(data)
items = []
for key, item in data.get("shortcuts", {}).items():
if not isinstance(item, dict):
continue
row = dict(item)
row["key"] = key
items.append(row)
return sorted(items, key=lambda item: item.get("name", "").lower())
def save_shortcut_item(payload):
item = normalize_shortcut_item(payload)
if item is None:
raise ValueError("Shortcut activation phrase and replacement phrase are required.")
data = load_shortcut_db()
shortcuts = data.setdefault("shortcuts", {})
key = shortcut_key(item["name"])
original_key = shortcut_key(payload.get("original_name", ""))
previous = shortcuts.get(key, {}) if isinstance(shortcuts.get(key), dict) else {}
if not previous and original_key:
previous = shortcuts.get(original_key, {}) if isinstance(shortcuts.get(original_key), dict) else {}
item["created_at"] = str(previous.get("created_at") or item["created_at"])
item["updated_at"] = now_iso()
shortcuts[key] = item
if original_key and original_key != key:
shortcuts.pop(original_key, None)
save_shortcut_db(data)
return key, data
def delete_shortcut_item(name):
key = shortcut_key(name)
data = load_shortcut_db()
if key:
data.setdefault("shortcuts", {}).pop(key, None)
save_shortcut_db(data)
return key, data
def shortcut_categories(data=None):
data = load_shortcut_db() if data is None else normalize_shortcut_db(data)
return data.get("categories", [])
def add_shortcut_category(name, sub_category=""):
"""Add a category (and optionally a sub-category under it) to the managed list."""
name = _clean_label(name)
if not name:
raise ValueError("Category name is required.")
data = load_shortcut_db()
_union_category(data.setdefault("categories", []), name, sub_category)
save_shortcut_db(data)
return load_shortcut_db()
# --- Custom transition DB --------------------------------------------------
def empty_transition_db():
return {"version": 1, "source": "ComfyUI-FunPack", "transitions": {}}
def normalize_transition_item(item, fallback_name=""):
if not isinstance(item, dict):
return None
trigger = re.sub(r"\s+", " ", str(item.get("trigger") or "").strip())
if not trigger:
return None
name = re.sub(r"\s+", " ", str(item.get("name") or fallback_name or trigger).strip())
placement = str(item.get("placement") or "global").strip().lower()
if placement not in ("global", "start", "end", "silent"):
placement = "global"
return {
"name": name,
"trigger": trigger,
"placement": placement,
"enabled": bool(item.get("enabled", True)),
}
def normalize_transition_db(data):
if not isinstance(data, dict):
data = empty_transition_db()
transitions = data.get("transitions", {})
if isinstance(transitions, list):
transitions = {str(i): item for i, item in enumerate(transitions)}
if not isinstance(transitions, dict):
transitions = {}
normalized = {}
for fallback_name, item in transitions.items():
entry = normalize_transition_item(item, fallback_name)
if not entry:
continue
key = shortcut_key(entry["name"]) or shortcut_key(fallback_name)
if key:
normalized[key] = entry
data["version"] = 1
data["source"] = "ComfyUI-FunPack"
data["transitions"] = normalized
return data
def load_transition_db():
_migrate_transition_store_if_needed()
path = transition_store_path()
if not os.path.exists(path):
return empty_transition_db()
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
return empty_transition_db()
return normalize_transition_db(data)
def save_transition_db(data):
data = normalize_transition_db(data)
os.makedirs(template_store_dir(), exist_ok=True)
with open(transition_store_path(), "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, sort_keys=True)
def transition_items(data=None):
data = load_transition_db() if data is None else normalize_transition_db(data)
items = []
for key, item in data.get("transitions", {}).items():
if not isinstance(item, dict):
continue
row = dict(item)
row["key"] = key
items.append(row)
return sorted(items, key=lambda item: item.get("name", "").lower())
def save_transition_item(payload):
entry = normalize_transition_item(payload)
if not entry:
raise ValueError("Transition trigger phrase is required.")
data = load_transition_db()
key = shortcut_key(entry["name"])
original_key = shortcut_key(str(payload.get("original_name") or ""))
transitions = data.setdefault("transitions", {})
transitions[key] = entry
if original_key and original_key != key:
transitions.pop(original_key, None)
save_transition_db(data)
return key, data
def delete_transition_item(name):
key = shortcut_key(name)
data = load_transition_db()
if key:
data.setdefault("transitions", {}).pop(key, None)
save_transition_db(data)
return key, data
def load_custom_transition_triggers():
"""Return {trigger: {"placement": override_or_None}} for enabled custom split markers.
placement is 'start', 'end', 'silent', or None (use global setting).
"""
data = load_transition_db()
result = {}
for item in data.get("transitions", {}).values():
if not isinstance(item, dict) or not item.get("enabled", True):
continue
trigger = re.sub(r"\s+", " ", str(item.get("trigger") or "").strip())
if not trigger:
continue
placement = str(item.get("placement") or "global").strip().lower()
result[trigger.lower()] = {
"placement": placement if placement in ("start", "end", "silent") else None,
"visual_effect": str(item.get("visual_effect") or "none"),
}
return result
def _shortcut_trigger_pattern(trigger):
words = [re.escape(part) for part in re.split(r"\s+", str(trigger or "").strip()) if part]
if not words:
return ""
body = r"\s+".join(words)
return rf"(?<![\w’’-])({body})(?![\w’’-])"
def _shortcut_replacements(raw):
"""Parse replacement list, allowing empty string as a valid ‘remove’ replacement.
A replacement is a prose phrase that legitimately contains commas/semicolons, so a raw
string is split on NEWLINES ONLY (the 'one per line' UI contract). Splitting on commas
tore one phrase into several variants and re-saving kept re-tearing it."""
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError, ValueError):
raw = raw.split("\n")
if not isinstance(raw, list):
return []
seen = set()
result = []
for item in raw:
text = re.sub(r"\s+", " ", str(item if item is not None else "").strip())
if text not in seen:
seen.add(text)
result.append(text)
return result
def _cleanup_removed_phrases(text):
"""Fix punctuation and spacing artifacts left by empty-replacement removals."""
text = re.sub(r"[ \t]+([,;])", r"\1", text)
text = re.sub(r"([,;])\s*([,;])+", r"\1", text)
text = re.sub(r"^[\s,;]+", "", text)
text = re.sub(r"[\s,;]+$", "", text)
text = re.sub(r"[ \t]{2,}", " ", text)
return text
# ── prompt variables ($name) ────────────────────────────────────────────────────
# Variables are a project-scoped find/replace layer resolved DEAD LAST — after shortcut
# expansion AND after the transition split — so they can never create or move a scene cut.
# A `$name` token is replaced with the variable's text; the text may itself reference other
# variables (recursive), undefined names are left as literal `$name`, and a variable that
# references itself anywhere in its own chain is a cycle (left literal, never expanded).
_VARIABLE_TOKEN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
_VARIABLE_MAX_DEPTH = 64
def _normalize_variables(variables):
"""Accept either {name: text} or [{"name":, "value":}] and return an ordered-safe dict.
Names are stripped of a leading `$` and surrounding whitespace; later entries win."""
out = {}
if isinstance(variables, dict):
items = list(variables.items())
elif isinstance(variables, list):
items = [
(v.get("name"), v.get("value", v.get("content", v.get("text", ""))))
for v in variables if isinstance(v, dict)
]
else:
items = []
for name, val in items:
key = str(name if name is not None else "").lstrip("$").strip()
if key:
out[key] = str(val if val is not None else "")
return out
def resolve_variables(text, variables):
"""Substitute `$name` tokens in `text` from `variables`. Recursive, cycle-safe (a name in
its own expansion chain is left literal), depth-guarded, undefined -> literal `$name`.
Returns (resolved_text, sorted list of undefined names referenced)."""
var_map = _normalize_variables(variables)
if not var_map:
return str(text or ""), []
undefined = set()
def _expand(s, stack, depth):
if depth > _VARIABLE_MAX_DEPTH:
return s
def _repl(m):
name = m.group(1)
if name not in var_map:
undefined.add(name)
return m.group(0) # undefined -> leave literal
if name in stack:
return m.group(0) # cycle -> leave literal, do not recurse
return _expand(var_map[name], stack | {name}, depth + 1)
return _VARIABLE_TOKEN.sub(_repl, s)
return _expand(str(text or ""), frozenset(), 0), sorted(undefined)
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None, revolver_commit=False):
"""Expand shortcut triggers. Multi-replacement picks are seeded-random unless the
shortcut revolver is enabled (see load_revolver): then each firing draws the next
replacement from the shortcut's no-repeat cycle. ``revolver_commit`` persists the
advanced cycle state — True only for real generation; previews peek without saving,
so they show exactly what the next generation will draw."""
original = str(text or "")
if not original:
return original, []
data = load_shortcut_db() if shortcut_db is None else normalize_shortcut_db(shortcut_db)
candidates = []
for db_key, shortcut in data.get("shortcuts", {}).items():
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
continue
replacements = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
if not replacements:
continue
sc_key = normalize_refinement_key(shortcut.get("refinement_key", ""))
for trigger in shortcut_list(shortcut.get("triggers", [])):
pattern = _shortcut_trigger_pattern(trigger)
if pattern:
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger),
sc_key, str(db_key)))
if not candidates:
return original, []
candidates.sort(key=lambda item: len(shortcut_key(item[0])), reverse=True)
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _, _) in enumerate(candidates))
if not combined:
return original, []
try:
rng_seed = int(seed or 0)
except (TypeError, ValueError):
rng_seed = 0
if rng_seed == 0:
rng_seed = int(md5(original.encode("utf-8")).hexdigest()[:12], 16)
rng = random.Random(rng_seed)
revolver = load_revolver()
revolver_on = bool(revolver.get("enabled"))
revolver_state = revolver.get("state")
revolver_random = bool(revolver.get("random"))
revolver_dirty = False
applied = []
removals_happened = False
def replace(match):
nonlocal removals_happened, revolver_dirty
for index, (trigger, _, replacements, name, sc_key, db_key) in enumerate(candidates):
if match.group(f"t{index}") is None:
continue
if revolver_on and len(replacements) > 1:
replacement = revolver_next_replacement(revolver_state, db_key, replacements, revolver_random)
revolver_dirty = True
else:
replacement = rng.choice(replacements)
applied.append({"name": str(name), "trigger": trigger,
"replacement": replacement, "refinement_key": sc_key})
if not replacement:
removals_happened = True
return replacement
return match.group(0)
expanded = re.sub(combined, replace, original, flags=re.IGNORECASE | re.UNICODE)
if removals_happened:
expanded = _cleanup_removed_phrases(expanded)
if revolver_dirty and revolver_commit:
try:
save_revolver(revolver)
except OSError:
pass
return expanded, applied
def normalize_template_name(value):
value = str(value or "").strip()
if not value or value == TEMPLATE_NONE:
return ""
return value
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
@PromptServer.instance.routes.get("/funpack/templates")
async def funpack_templates(_):
return web.json_response(
{"templates": template_names(), "path": template_store_path()},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/templates/export")
async def funpack_templates_export(_):
data = load_template_db()
return web.json_response(
data,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": "attachment; filename=funpack_templates.json",
},
)
@PromptServer.instance.routes.post("/funpack/templates/import")
async def funpack_templates_import(request):
incoming = await request.json()
templates = incoming.get("templates") if isinstance(incoming, dict) else None
if not isinstance(templates, dict):
return web.json_response({"error": "Imported file does not contain a templates object."}, status=400)
data = load_template_db()
current = data.setdefault("templates", {})
imported = 0
for name, template in templates.items():
clean_name = normalize_template_name(name)
if not clean_name or not isinstance(template, dict):
continue
item = dict(template)
item["name"] = clean_name
item["updated_at"] = now_iso()
current[clean_name] = item
imported += 1
save_template_db(data)
return web.json_response({"imported": imported, "templates": template_names()})
@PromptServer.instance.routes.get("/funpack/shortcuts")
async def funpack_shortcuts(_):
data = load_shortcut_db()
return web.json_response(
{
"path": shortcut_store_path(),
"data": data,
"shortcuts": shortcut_items(data),
"categories": shortcut_categories(data),
},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/shortcuts/export")
async def funpack_shortcuts_export(_):
data = load_shortcut_db()
return web.json_response(
data,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": "attachment; filename=funpack_shortcuts.json",
},
)
@PromptServer.instance.routes.post("/funpack/shortcuts/shortcut")
async def funpack_shortcut_save(request):
body = await request.json()
if not isinstance(body, dict):
return web.json_response({"error": "Shortcut payload must be an object."}, status=400)
action = str(body.get("action") or "save").lower()
name = normalize_shortcut_name(body.get("name"))
if action == "delete":
if not name:
return web.json_response({"error": "Shortcut name is required."}, status=400)
key, data = delete_shortcut_item(name)
return web.json_response({"deleted": key, "data": data, "shortcuts": shortcut_items(data), "categories": shortcut_categories(data)})
try:
key, data = save_shortcut_item(body)
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response({"saved": key, "data": data, "shortcuts": shortcut_items(data), "categories": shortcut_categories(data)})
@PromptServer.instance.routes.post("/funpack/shortcuts/import")
async def funpack_shortcuts_import(request):
incoming = await request.json()
if not isinstance(incoming, dict):
return web.json_response({"error": "Imported file is not a shortcut database."}, status=400)
if "shortcuts" not in incoming:
return web.json_response({"error": "Imported file does not contain shortcuts."}, status=400)
imported = normalize_shortcut_db(incoming)
data = load_shortcut_db()
shortcuts = data.setdefault("shortcuts", {})
count = 0
for key, item in imported.get("shortcuts", {}).items():
if isinstance(item, dict):
shortcuts[str(key)] = item
count += 1
save_shortcut_db(data)
data = load_shortcut_db()
return web.json_response({"imported": count, "data": data, "shortcuts": shortcut_items(data), "categories": shortcut_categories(data)})
@PromptServer.instance.routes.get("/funpack/transitions")
async def funpack_transitions(_):
data = load_transition_db()
return web.json_response(
{"path": transition_store_path(), "data": data, "transitions": transition_items(data)},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/transitions/export")
async def funpack_transitions_export(_):
data = load_transition_db()
return web.json_response(
data,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": "attachment; filename=funpack_promptsplit.json",
},
)
@PromptServer.instance.routes.post("/funpack/transitions/transition")
async def funpack_transition_save(request):
body = await request.json()
if not isinstance(body, dict):
return web.json_response({"error": "Transition payload must be an object."}, status=400)
action = str(body.get("action") or "save").lower()
name = str(body.get("name") or body.get("trigger") or "").strip()
if action == "delete":
if not name:
return web.json_response({"error": "Transition name is required."}, status=400)
key, data = delete_transition_item(name)
return web.json_response({"deleted": key, "data": data, "transitions": transition_items(data)})
try:
key, data = save_transition_item(body)
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response({"saved": key, "data": data, "transitions": transition_items(data)})
@PromptServer.instance.routes.post("/funpack/transitions/import")
async def funpack_transitions_import(request):
incoming = await request.json()
if not isinstance(incoming, dict):
return web.json_response({"error": "Imported file is not a transition database."}, status=400)
if "transitions" not in incoming:
return web.json_response({"error": "Imported file does not contain transitions."}, status=400)
imported_db = normalize_transition_db(incoming)
data = load_transition_db()
entries = data.setdefault("transitions", {})
count = 0
for key, item in imported_db.get("transitions", {}).items():
if isinstance(item, dict):
entries[str(key)] = item
count += 1
save_transition_db(data)
data = load_transition_db()
return web.json_response({"imported": count, "data": data, "transitions": transition_items(data)})
@PromptServer.instance.routes.post("/funpack/parse_timeline")
async def funpack_parse_timeline(request):
data = await request.json()
prompt = str(data.get("prompt") or "")
seed = int(data.get("seed") or 0)
try:
from .conditioning import parse_timeline_segments
except ImportError:
from conditioning import parse_timeline_segments
expanded, _ = apply_prompt_shortcuts(prompt, seed=seed)
return web.json_response(parse_timeline_segments(expanded))
@PromptServer.instance.routes.get("/funpack/refinement_keys")
async def funpack_refinement_keys(_):
return web.json_response(
{"keys": refinement_key_names(), "path": refinement_store_dir()},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/refinement_keys/export")
async def funpack_refinement_keys_export(request):
key = normalize_refinement_key(request.query.get("key", ""))
state, loaded_key, status = load_refinement_key_state(key, create=False)
if state is None:
return web.json_response({"error": status}, status=404)
return web.json_response(
state,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": f"attachment; filename={loaded_key}.json",
},
)
def _truthy(value):
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _save_imported_refinement_key(incoming, overwrite=False):
"""Shared tail for the one-shot and chunked import routes. Returns an aiohttp
response. When a key of the same name already exists and ``overwrite`` is not
set, replies 409 with ``{"exists": True, "key": ...}`` so the client can ask
the user to overwrite or cancel."""
state, key = normalize_refinement_state(_coerce_refinement_payload(incoming) or {})
if state is None:
return web.json_response({"error": "Imported file is not a valid V2 refinement key JSON."}, status=400)
if not overwrite and os.path.exists(refinement_key_path(key)):
return web.json_response(
{"exists": True, "key": key,
"error": f"A refinement key named '{key}' already exists."},
status=409,
)
path = save_refinement_key_state(state, key)
if not path:
return web.json_response({"error": "Could not save imported refinement key."}, status=400)
return web.json_response({"imported": key, "keys": refinement_key_names()})
@PromptServer.instance.routes.post("/funpack/refinement_keys/import")
async def funpack_refinement_keys_import(request):
incoming = await request.json()
return _save_imported_refinement_key(incoming, overwrite=_truthy(request.query.get("overwrite")))
# --- Chunked import -------------------------------------------------------------------
# Reverse proxies in front of ComfyUI on rented GPUs (Vast.ai, Runpod) cap the
# request body well below ComfyUI's own 100 MB limit, so a large refinement key
# POSTed in one shot is rejected with HTTP 413 before it ever reaches us. The
# client streams the key in small chunks (each well under any proxy limit) which
# we reassemble on disk, then finalize parses + saves the whole thing.
def _refinement_upload_dir():
directory = os.path.join(refinement_store_dir(), ".uploads")
os.makedirs(directory, exist_ok=True)
return directory
def _refinement_upload_path(upload_id):
safe = re.sub(r"[^A-Za-z0-9_-]", "", str(upload_id or ""))[:80]
if not safe:
return None
return os.path.join(_refinement_upload_dir(), f"{safe}.part")
@PromptServer.instance.routes.post("/funpack/refinement_keys/import_chunk")
async def funpack_refinement_keys_import_chunk(request):
part_path = _refinement_upload_path(request.query.get("upload_id"))
if not part_path:
return web.json_response({"error": "Missing or invalid upload id."}, status=400)
try:
index = int(request.query.get("index", "0"))
except (TypeError, ValueError):
return web.json_response({"error": "Invalid chunk index."}, status=400)
chunk = await request.read()
# index 0 starts (or restarts) the upload; later chunks append in order.
with open(part_path, "wb" if index <= 0 else "ab") as file:
file.write(chunk)
return web.json_response({"ok": True, "received": os.path.getsize(part_path)})
@PromptServer.instance.routes.post("/funpack/refinement_keys/import_finalize")
async def funpack_refinement_keys_import_finalize(request):
part_path = _refinement_upload_path(request.query.get("upload_id"))
if not part_path or not os.path.exists(part_path):
return web.json_response({"error": "Upload not found or expired."}, status=404)
try:
with open(part_path, "r", encoding="utf-8") as file:
incoming = json.load(file)
except (json.JSONDecodeError, OSError, ValueError) as exc:
return web.json_response({"error": f"Uploaded data is not valid JSON: {exc}"}, status=400)
finally:
try:
os.remove(part_path)
except OSError:
pass
return _save_imported_refinement_key(incoming, overwrite=_truthy(request.query.get("overwrite")))
@PromptServer.instance.routes.post("/funpack/refinement_keys/delete")
async def funpack_refinement_keys_delete(request):
key = normalize_refinement_key(request.query.get("key", ""))
if not key:
try:
body = await request.json()
except Exception:
body = {}
key = normalize_refinement_key((body or {}).get("key", ""))
if not key:
return web.json_response({"error": "No refinement key specified."}, status=400)
result = delete_refinement_key(key)
result["keys"] = refinement_key_names()
return web.json_response(result, headers={"Cache-Control": "no-store, max-age=0"})
@PromptServer.instance.routes.get("/funpack/refinement_keys/absolute")
async def funpack_refinement_keys_absolute(_):
return web.json_response(absolute_store_info(),
headers={"Cache-Control": "no-store, max-age=0"})
@PromptServer.instance.routes.post("/funpack/refinement_keys/clear_absolute")
async def funpack_refinement_keys_clear_absolute(_):
result = clear_absolute_store()
result.update(absolute_store_info())
return web.json_response(result, headers={"Cache-Control": "no-store, max-age=0"})
@PromptServer.instance.routes.get("/funpack/available_loras")
async def funpack_available_loras(request):
try:
import folder_paths as _fp
loras = _fp.get_filename_list("loras")
except Exception:
loras = []
return web.json_response(
{"loras": sorted(loras)},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/phrase_memory")
async def funpack_phrase_memory(request):
key = normalize_refinement_key(request.query.get("key", ""))
if not key:
return web.json_response({"phrases": [], "key": ""})
state, loaded_key, _ = load_refinement_key_state(key, create=False)
if not isinstance(state, dict):
return web.json_response({"phrases": [], "key": key})
global_state = state.get("global", {})
phrase_memory = global_state.get("phrase_memory", {}) if isinstance(global_state, dict) else {}
phrases = []
for text, entry in (phrase_memory.items() if isinstance(phrase_memory, dict) else []):
if not isinstance(entry, dict):
continue
clean = str(entry.get("text", text) or text).strip()
if not clean:
continue
phrases.append({
"text": clean,
"category": str(entry.get("primary", "") or entry.get("machine_primary", "") or "details"),
"evidence": int(entry.get("occurrence_count", 0) or 0),
})
phrases.sort(key=lambda p: (-p["evidence"], p["text"]))
return web.json_response(
{"phrases": phrases, "key": loaded_key or key},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/value_function/export")
async def funpack_vf_export(request):
from conditioning import refinement_state_path
key = normalize_refinement_key(request.query.get("key", ""))
if not key:
return web.json_response({"error": "No refinement key provided."}, status=400)
path = refinement_state_path(key, "value_fn", prefix="refine_v2", extension="pt")
if not os.path.exists(path):
return web.json_response({"error": "No value function found for this key."}, status=404)
with open(path, "rb") as f:
data = f.read()
safe_key = re.sub(r"[^\w\-]", "_", key)[:64]
return web.Response(
body=data,
content_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="funpack_vf_{safe_key}.pt"'},
)
@PromptServer.instance.routes.post("/funpack/value_function/import")
async def funpack_vf_import(request):
from conditioning import refinement_state_path
try:
from value_function import OnlineValueFunction
except ImportError:
return web.json_response({"error": "value_function module not available."}, status=500)
key = normalize_refinement_key(request.query.get("key", ""))
if not key:
return web.json_response({"error": "No refinement key provided."}, status=400)
raw = await request.read()
if not raw:
return web.json_response({"error": "Empty file."}, status=400)
import io
try:
with torch.inference_mode(False):
vf = OnlineValueFunction.load(io.BytesIO(raw))
except Exception as e:
return web.json_response({"error": f"Invalid value function file: {e}"}, status=400)
dest = refinement_state_path(key, "value_fn", prefix="refine_v2", extension="pt")
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(raw)
return web.json_response({"ok": True, "n_trained": vf.n_trained, "buffer": len(vf.buffer_c)})
class FunPackRefinementKeyLoader:
CATEGORY = "FunPack/Refinement"
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("refinement_key", "status")
FUNCTION = "load_refinement_key"
OUTPUT_NODE = True
DESCRIPTION = "Loads, creates, imports, and exports FunPack Video Refiner V2 refinement keys."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"refinement_key": (refinement_key_names(), {"default": REFINEMENT_KEY_NONE}),
"key_name": ("STRING", {"default": "", "multiline": False}),
"create_if_missing": ("BOOLEAN", {"default": True}),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def VALIDATE_INPUTS(cls, refinement_key=None, **kwargs):
return True
def load_refinement_key(self, refinement_key, key_name, create_if_missing=True):
selected = normalize_refinement_key(refinement_key)
typed = normalize_refinement_key(key_name)
target = selected or typed
if not target:
return ("", "No refinement key selected. Pick an existing key or type a new key name.")
_, loaded_key, status = load_refinement_key_state(target, create=bool(create_if_missing))
return (loaded_key if loaded_key else target, status)