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 .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 .json. Sidecars in the same folder are either # non-.json (value_fn .pt, latents .pt) or carry a mode suffix (.sampler_ctx.json), # and legacy installs may still hold opaque _.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 .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 .json plus its sidecars (.value_fn.pt, .sampler_ctx.json, and any other ..* 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 .* 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 .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 .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": , "sub_categories": [, ...]}. 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[]`` = {"fp": , "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"(? 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{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)