Files
olivv-cs-ComfyUI-FunPack/templates.py
T
digital-garbageandClaude Sonnet 4.6 f1b54da9dd Add value function export/import to Studio
Two server routes (GET /funpack/value_function/export, POST /funpack/value_function/import)
serve and receive the .pt binary. Studio Session tab gains Export and Import buttons
below the value guidance toggle, keyed to the active refinement key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:04:10 +03:00

1976 lines
73 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_DB_VERSION = 1
SCENE_STATE_FIELD = "scene_builder"
SCENE_CATEGORY_SOURCES = {"auto", "refiner", "user"}
WILDCARD_RE = re.compile(r"\{([^{}]*\|[^{}]*)\}")
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 scene_store_path():
return os.path.join(template_store_dir(), "scenes.json")
def shortcut_store_path():
return os.path.join(template_store_dir(), "shortcuts.json")
def transition_store_path():
return os.path.join(template_store_dir(), "transitions.json")
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()
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 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():
keys = set()
directory = refinement_store_dir()
if os.path.isdir(directory):
for filename in os.listdir(directory):
if not filename.startswith("refine_v2_") or not filename.endswith(".json"):
continue
path = os.path.join(directory, filename)
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
key = normalize_refinement_key(data.get("refinement_key") if isinstance(data, dict) else "")
if key:
keys.add(key)
except (json.JSONDecodeError, OSError, ValueError):
continue
return [REFINEMENT_KEY_NONE] + sorted(keys)
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_scene_db():
return {
"version": SCENE_DB_VERSION,
"source": "ComfyUI-FunPack",
"universal_memory": {},
"scenes": {},
}
def empty_shortcut_db():
return {
"version": 1,
"source": "ComfyUI-FunPack",
"shortcuts": {},
}
def normalize_scene_db(data):
if not isinstance(data, dict):
data = empty_scene_db()
data.setdefault("version", SCENE_DB_VERSION)
data.setdefault("source", "ComfyUI-FunPack")
if not isinstance(data.get("universal_memory"), dict):
data["universal_memory"] = {}
if not isinstance(data.get("scenes"), dict):
data["scenes"] = {}
return data
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,
"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
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)
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
# --- Custom transition DB --------------------------------------------------
def empty_transition_db():
return {"version": 1, "source": "ComfyUI-FunPack", "transitions": {}}
_VISUAL_EFFECTS = ("none", "fade_to_black", "crossfade", "blur_out_in")
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"
visual_effect = str(item.get("visual_effect") or "none").strip().lower()
if visual_effect not in _VISUAL_EFFECTS:
visual_effect = "none"
return {
"name": name,
"trigger": trigger,
"placement": placement,
"enabled": bool(item.get("enabled", True)),
"visual_effect": visual_effect,
}
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():
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, "visual_effect": effect}} for enabled custom transitions.
placement is 'start', 'end', 'silent', or None (use global setting).
visual_effect is one of 'none', 'fade_to_black', 'crossfade', 'blur_out_in'.
"""
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()
visual_effect = str(item.get("visual_effect") or "none").strip().lower()
if visual_effect not in _VISUAL_EFFECTS:
visual_effect = "none"
result[trigger.lower()] = {
"placement": placement if placement in ("start", "end", "silent") else None,
"visual_effect": visual_effect,
}
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."""
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError, ValueError):
raw = re.split(r"[,;\n]+", raw)
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
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
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 shortcut in data.get("shortcuts", {}).values():
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
for trigger in shortcut_list(shortcut.get("triggers", [])):
pattern = _shortcut_trigger_pattern(trigger)
if pattern:
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger)))
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)
applied = []
removals_happened = False
def replace(match):
nonlocal removals_happened
for index, (trigger, _, replacements, name) in enumerate(candidates):
if match.group(f"t{index}") is None:
continue
replacement = rng.choice(replacements)
applied.append({"name": str(name), "trigger": trigger, "replacement": replacement})
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)
return expanded, applied
def load_scene_db(refinement_key=""):
key = normalize_refinement_key(refinement_key)
if key:
state, _, _ = load_refinement_key_state(key, create=False)
if isinstance(state, dict):
return normalize_scene_db(state.get(SCENE_STATE_FIELD, {}))
return empty_scene_db()
path = scene_store_path()
if not os.path.exists(path):
return empty_scene_db()
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
return empty_scene_db()
return normalize_scene_db(data)
def save_scene_db(data, refinement_key=""):
key = normalize_refinement_key(refinement_key)
data = normalize_scene_db(data)
if key:
state, _, _ = load_refinement_key_state(key, create=True)
if isinstance(state, dict):
state[SCENE_STATE_FIELD] = data
save_refinement_key_state(state, key)
return
os.makedirs(template_store_dir(), exist_ok=True)
with open(scene_store_path(), "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, sort_keys=True)
def scene_names(refinement_key=""):
data = load_scene_db(refinement_key)
names = sorted(
name for name in data.get("scenes", {}).keys()
if isinstance(name, str) and name.strip()
)
return [SCENE_NONE] + names
def normalize_scene_name(value):
value = str(value or "").strip()
if not value or value == SCENE_NONE:
return ""
return value
def normalize_scene_key(value):
value = re.sub(r"[^\w'’]+", " ", str(value or "").strip().lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", value).strip()
def normalize_scene_text_spacing(value):
text = re.sub(r"[ \t\r\f\v]+", " ", str(value or "")).strip()
text = re.sub(r"\s+([,.])", r"\1", text)
text = re.sub(r"([,.])([^\s,.])", r"\1 \2", text)
return text.strip()
def scene_token_words(text):
return [
token.lower()
for token in re.findall(r"[\w'’.-]+", str(text or ""), flags=re.UNICODE)
if any(char.isalpha() for char in token) and len(token.strip("._-")) >= 2
]
def categorize_scene_phrase(text, source="positive"):
if source == "negative":
return "negative"
words = set(scene_token_words(text))
best = ("details", 0)
for category, keywords in SCENE_CATEGORIES.items():
if category == "negative":
continue
score = len(words & keywords)
if score > best[1]:
best = (category, score)
return best[0]
def extract_scene_phrases(text, source="positive", include_words=False):
phrases = []
seen = set()
for raw in re.split(r"[,;.\n]+", normalize_scene_text_spacing(text)):
clean = re.sub(r"\s+", " ", raw).strip(" ,;:.").strip()
if len(clean) < 2:
continue
key = clean.lower()
if key in seen:
continue
words = scene_token_words(clean)
if not words:
continue
seen.add(key)
phrases.append({
"text": clean,
"key": key,
"source": source,
"category": categorize_scene_phrase(clean, source),
"tokens": words,
})
if include_words:
for word in words:
word_key = word.lower().strip("._-")
if len(word_key) < 3 or word_key in SCENE_STOP_WORDS or word_key in seen:
continue
seen.add(word_key)
phrases.append({
"text": word_key,
"key": word_key,
"source": source,
"category": categorize_scene_phrase(word_key, source),
"tokens": [word_key],
})
return phrases
def remember_scene_phrases(data, positive_prompt="", negative_prompt=""):
memory = data.setdefault("universal_memory", {})
changed = False
for source, prompt in (("positive", positive_prompt), ("negative", negative_prompt)):
for phrase in extract_scene_phrases(prompt, source, include_words=True):
key = phrase["key"]
current = memory.setdefault(key, {
"text": phrase["text"],
"source": source,
"category": phrase["category"],
"tokens": phrase["tokens"],
"count": 0,
"created_at": now_iso(),
"category_source": "auto",
"category_locked": False,
})
if not bool(current.get("category_locked")):
current["text"] = current.get("text") or phrase["text"]
current["source"] = source
current["category"] = phrase["category"]
current["tokens"] = phrase["tokens"]
current["category_source"] = str(current.get("category_source") or "auto")
current["count"] = int(current.get("count", 0) or 0) + 1
current["updated_at"] = now_iso()
changed = True
return changed
def scene_memory_items(data):
memory = data.get("universal_memory", {}) if isinstance(data, dict) else {}
items = []
for key, item in memory.items():
if not isinstance(item, dict):
continue
text = str(item.get("text") or key).strip()
if not text:
continue
items.append({
"text": text,
"key": str(key),
"source": item.get("source", "positive"),
"category": item.get("category", "details"),
"category_source": item.get("category_source", "auto"),
"category_locked": bool(item.get("category_locked")),
"tokens": item.get("tokens", scene_token_words(text)),
"count": int(item.get("count", 0) or 0),
"wildcard": bool(item.get("wildcard")) or bool(str(item.get("wildcard_group") or "").strip()),
})
return sorted(items, key=lambda item: (item["category"], item["text"].lower()))
def normalize_scene_memory_items(value):
if isinstance(value, dict):
iterable = value.items()
elif isinstance(value, list):
iterable = ((None, item) for item in value)
else:
return {}
memory = {}
for fallback_key, item in iterable:
if isinstance(item, str):
item = {"text": item}
if not isinstance(item, dict):
continue
text = re.sub(r"\s+", " ", str(item.get("text") or fallback_key or "").strip())
if not text:
continue
key = normalize_scene_key(item.get("key") or text) or text.lower()
source = "negative" if item.get("source") == "negative" or item.get("category") == "negative" else "positive"
category = str(item.get("category") or categorize_scene_phrase(text, source)).strip().lower()
if category not in SCENE_CATEGORIES:
category = "negative" if source == "negative" else "details"
tokens = scene_token_words(text)
wildcard = bool(item.get("wildcard")) or bool(str(item.get("wildcard_group") or "").strip())
category_source = str(item.get("category_source") or "auto").strip().lower()
if category_source not in SCENE_CATEGORY_SOURCES:
category_source = "user" if bool(item.get("category_locked")) else "auto"
category_locked = bool(item.get("category_locked")) or category_source == "user"
memory[key] = {
"text": text,
"source": source,
"category": category,
"category_source": category_source,
"category_locked": category_locked,
"tokens": tokens,
"count": max(0, int(item.get("count", 0) or 0)),
"created_at": str(item.get("created_at") or now_iso()),
"updated_at": now_iso(),
"wildcard": wildcard,
}
return memory
def scene_memory_authority_changed(previous, current):
if not isinstance(previous, dict) or not isinstance(current, dict):
return True
return any(
str(previous.get(field, "")).strip().lower() != str(current.get(field, "")).strip().lower()
for field in ("text", "source", "category")
)
def apply_scene_database_authority(previous_memory, incoming_memory):
previous_memory = previous_memory if isinstance(previous_memory, dict) else {}
incoming_memory = incoming_memory if isinstance(incoming_memory, dict) else {}
resolved = {}
for key, item in incoming_memory.items():
if not isinstance(item, dict):
continue
current = dict(item)
previous = previous_memory.get(key)
if isinstance(previous, dict) and not scene_memory_authority_changed(previous, current):
current["category_source"] = str(previous.get("category_source") or "auto")
current["category_locked"] = bool(previous.get("category_locked"))
else:
current["category_source"] = "user"
current["category_locked"] = True
resolved[key] = current
return resolved
def normalize_scene_phrase_list(value):
if isinstance(value, str):
try:
value = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
value = [value]
if not isinstance(value, list):
return []
result = []
seen = set()
for item in value:
text = item.get("text") if isinstance(item, dict) else item
clean = normalize_scene_text_spacing(text).strip(" ,;:.").strip()
key = clean.lower()
if not clean or key in seen:
continue
seen.add(key)
result.append(clean)
return result
def parse_scene_payload(value):
if isinstance(value, dict):
payload = value
elif isinstance(value, str) and value.strip():
try:
payload = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
payload = {}
else:
payload = {}
if not isinstance(payload, dict):
payload = {}
return {
"positive_phrases": normalize_scene_phrase_list(payload.get("positive_phrases", [])),
"negative_phrases": normalize_scene_phrase_list(payload.get("negative_phrases", [])),
}
def scene_text_or_payload(text, phrases):
text = str(text or "").strip()
if text:
return text
return scene_text_from_phrases(phrases)
def scene_payload_from_scene(scene):
if not isinstance(scene, dict):
return {"positive_phrases": [], "negative_phrases": []}
return {
"positive_phrases": normalize_scene_phrase_list(scene.get("positive_phrases", [])),
"negative_phrases": normalize_scene_phrase_list(scene.get("negative_phrases", [])),
}
def scene_text_from_phrases(phrases):
return ", ".join(normalize_scene_phrase_list(phrases))
def scene_prompt_text(scene, text_field, phrases_field):
if not isinstance(scene, dict):
return ""
if text_field in scene:
return str(scene.get(text_field) or "")
return scene_text_from_phrases(scene.get(phrases_field, []))
def scene_from_texts(name, aliases, output_mode, positive_text, negative_text, refinement_key=""):
positive_text = normalize_scene_text_spacing(positive_text)
negative_text = normalize_scene_text_spacing(negative_text)
return {
"name": normalize_scene_name(name),
"aliases": aliases_from_text(aliases),
"output_mode": output_mode if output_mode in {"Manual", "Auto", "Learning"} else "Manual",
"refinement_key": normalize_refinement_key(refinement_key),
"positive_text": positive_text,
"negative_text": negative_text,
"positive_phrases": normalize_scene_phrase_list(extract_scene_phrases(positive_text, "positive")),
"negative_phrases": normalize_scene_phrase_list(extract_scene_phrases(negative_text, "negative")),
}
def aliases_from_text(value):
aliases = []
seen = set()
if isinstance(value, list):
raw_values = value
else:
raw_values = re.split(r"[,;\n]+", str(value or ""))
for raw in raw_values:
clean = re.sub(r"\s+", " ", raw).strip()
key = normalize_scene_key(clean)
if clean and key not in seen:
seen.add(key)
aliases.append(clean)
return aliases
def scene_exact_match(intent_prompt, scenes):
intent = normalize_scene_key(intent_prompt)
if not intent:
return "", None
padded = f" {intent} "
for name, scene in sorted(scenes.items(), key=lambda item: len(item[0]), reverse=True):
keys = [name]
if isinstance(scene, dict):
keys.extend(scene.get("aliases", []) if isinstance(scene.get("aliases"), list) else [])
for key in keys:
clean = normalize_scene_key(key)
if clean and f" {clean} " in padded:
return name, scene
return "", None
def scene_fuzzy_match(intent_prompt, scenes):
intent_words = set(scene_token_words(intent_prompt))
if not intent_words:
return "", None
best = ("", None, 0.0)
for name, scene in scenes.items():
keys = [name]
if isinstance(scene, dict):
keys.extend(scene.get("aliases", []) if isinstance(scene.get("aliases"), list) else [])
for key in keys:
key_words = set(scene_token_words(key))
if not key_words:
continue
overlap = len(intent_words & key_words)
if overlap < max(1, len(key_words)):
continue
score = overlap / max(len(key_words), 1)
if score > best[2]:
best = (name, scene, score)
return best[0], best[1]
def find_scene_for_intent(intent_prompt, scenes):
name, scene = scene_exact_match(intent_prompt, scenes)
if name:
return name, scene, "exact"
name, scene = scene_fuzzy_match(intent_prompt, scenes)
if name:
return name, scene, "fuzzy"
return "", None, "none"
def scene_field_summary(scene):
if not isinstance(scene, dict):
return "none"
fields = []
for field in ("positive_phrases", "negative_phrases", "refinement_key"):
if scene.get(field):
fields.append(field)
if scene.get("positive_text"):
fields.append("positive_text")
if scene.get("negative_text"):
fields.append("negative_text")
return ", ".join(fields) if fields else "none"
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()
def json_safe(value):
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
return copy.deepcopy(value)
def maybe_store_string(template, field, value, update_only):
if isinstance(value, str) and value.strip():
template[field] = value
elif not update_only:
template.pop(field, None)
def collect_template_payload(
mode,
activation_word="",
refinement_key="",
positive_prompt="",
negative_prompt="",
sigmas=None,
lora_stack=None,
update_only=False,
existing=None,
):
template = dict(existing or {}) if update_only else {}
template["mode"] = mode if mode in {"ltx2", "wan"} else "ltx2"
maybe_store_string(template, "activation_word", activation_word, update_only)
maybe_store_string(template, "refinement_key", refinement_key, update_only)
maybe_store_string(template, "positive_prompt", positive_prompt, update_only)
maybe_store_string(template, "negative_prompt", negative_prompt, update_only)
if isinstance(sigmas, torch.Tensor):
template["sigmas"] = tensor_to_serializable(sigmas.detach().cpu())
elif not update_only:
template.pop("sigmas", None)
if isinstance(lora_stack, dict):
template["lora_stack"] = json_safe(lora_stack)
elif not update_only:
template.pop("lora_stack", None)
return template
def resolve_wildcards(text, seed=0):
text = str(text or "")
if not text:
return text
rng = random.Random(int(seed)) if int(seed or 0) != 0 else random.Random()
def replace(match):
choices = [item.strip() for item in match.group(1).split("|")]
choices = [item for item in choices if item]
if not choices:
return match.group(0)
return rng.choice(choices)
previous = None
current = text
while previous != current:
previous = current
current = WILDCARD_RE.sub(replace, current)
return current
def resolve_scene_database_wildcards(text, scene_db):
text = str(text or "")
if not text or not isinstance(scene_db, dict):
return text
wildcard_phrases = {}
for item in scene_db.get("universal_memory", {}).values():
if not isinstance(item, dict):
continue
wildcard = bool(item.get("wildcard")) or bool(str(item.get("wildcard_group") or "").strip())
phrase = str(item.get("text") or "").strip()
if not wildcard or not phrase:
continue
wildcard_phrases[normalize_scene_key(phrase)] = phrase
if not wildcard_phrases:
return text
parts = re.split(r"([,;.\n]+)", text)
changed = False
run = []
def flush_run():
nonlocal changed, run
unique_keys = sorted({phrase_key for _, phrase_key in run})
if len(unique_keys) >= 2:
keep = random.choice(unique_keys)
for index, phrase_key in run:
if phrase_key != keep:
parts[index] = ""
changed = True
run = []
for index in range(0, len(parts), 2):
phrase_key = normalize_scene_key(parts[index])
if phrase_key and phrase_key in wildcard_phrases:
run.append((index, phrase_key))
else:
flush_run()
flush_run()
if not changed:
return text
output = "".join(parts)
output = re.sub(r"\s*([,;.])\s*([,;.]\s*)+", r"\1 ", output)
output = re.sub(r"(?:^|[\s,;.])+\n", "\n", output)
output = re.sub(r"\s+", " ", output.replace("\n", ", ")).strip(" ,;.")
return output
def prompt_key_for_refiner(prompt, mode):
del mode
return re.sub(r"\s+", " ", str(prompt or "").strip())
def load_refiner_state(refinement_key, mode):
if not refinement_key:
return None, "No refinement key stored."
candidates = (
(refinement_state_path(refinement_key, "clip", prefix="refine_v2"), "V2 refiner state"),
(refinement_state_path(refinement_key, mode), "legacy refiner state"),
)
for path, label in candidates:
if not os.path.exists(path):
continue
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
if isinstance(data, dict):
data["_funpack_state_label"] = label
return data, f"{label} loaded for key '{refinement_key}'."
except (json.JSONDecodeError, OSError, ValueError):
return None, f"{label} for key '{refinement_key}' is unreadable."
return None, f"No refiner state found for key '{refinement_key}'."
def choose_prompt_history(data, prompt, mode):
prompt_histories = data.get("prompt_histories", {}) if isinstance(data, dict) else {}
if not isinstance(prompt_histories, dict) or not prompt_histories:
return None, "", "No prompt histories in refiner state."
prompt_key = prompt_key_for_refiner(prompt, mode)
if prompt_key in prompt_histories and isinstance(prompt_histories[prompt_key], dict):
return prompt_histories[prompt_key], prompt_key, "Matched exact prompt history."
last_key = data.get("last_prompt_key") if isinstance(data, dict) else None
if isinstance(last_key, str) and isinstance(prompt_histories.get(last_key), dict):
return prompt_histories[last_key], last_key, "Used latest prompt history."
for key, history in prompt_histories.items():
if isinstance(history, dict):
return history, key, "Used first available prompt history."
return None, "", "No usable prompt history found."
def best_history_conditioning(history):
if not isinstance(history, dict):
return None, "No prompt history selected."
liked = history.get("liked_reference_embeds")
if liked is not None and int(history.get("liked_reference_count", 0) or 0) > 0:
return liked, "Loaded liked-average conditioning."
best_entry = None
best_score = None
for entry in history.get("history", []):
if not isinstance(entry, dict) or entry.get("modified_embeds") is None:
continue
profile = normalize_refiner_rating(entry.get("rating_label", entry.get("rating", 0)))
score = (
int(profile.get("level", 0)),
float(profile.get("reward", 0.0)),
int(entry.get("iteration", 0)),
)
if best_score is None or score > best_score:
best_entry = entry
best_score = score
if best_entry is not None:
return best_entry.get("modified_embeds"), "Loaded best-rated history conditioning."
reference = history.get("reference_embeds") or history.get("source_conditioning_embeds")
if reference is not None:
return reference, "Loaded stored reference conditioning."
return None, "No conditioning data found in selected history."
def conditioning_from_refiner(refinement_key, mode, prompt):
data, state_status = load_refiner_state(refinement_key, mode)
if data is None:
return None, state_status
history, _, history_status = choose_prompt_history(data, prompt, mode)
serialized, conditioning_status = best_history_conditioning(history)
if serialized is None and isinstance(data, dict):
global_state = data.get("global") if isinstance(data.get("global"), dict) else {}
if isinstance(global_state.get("liked_conditioning"), dict):
serialized = global_state.get("liked_conditioning")
conditioning_status = "Loaded V2 liked-average conditioning."
elif isinstance(data.get("last_run"), dict) and isinstance(data["last_run"].get("conditioning"), dict):
serialized = data["last_run"].get("conditioning")
conditioning_status = "Loaded V2 latest-run conditioning."
if serialized is None:
return None, f"{state_status} {history_status} {conditioning_status}"
try:
tensor = serializable_to_tensor(serialized)
except Exception as error:
return None, f"{state_status} {history_status} Failed to restore conditioning: {error}"
return [(tensor, {"pooled_output": None})], f"{state_status} {history_status} {conditioning_status}"
def template_field_summary(template):
fields = []
for field in ("positive_prompt", "negative_prompt", "activation_word", "refinement_key", "sigmas", "lora_stack"):
if field in template:
fields.append(field)
return ", ".join(fields) if fields else "none"
@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/scenes")
async def funpack_scenes(request):
key = normalize_refinement_key(request.query.get("key", ""))
data = load_scene_db(key)
return web.json_response(
{
"scenes": scene_names(key),
"path": refinement_key_path(key) if key else scene_store_path(),
"refinement_key": key,
"data": data,
"memory": scene_memory_items(data),
},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/scenes/export")
async def funpack_scenes_export(request):
key = normalize_refinement_key(request.query.get("key", ""))
data = load_scene_db(key)
return web.json_response(
data,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": f"attachment; filename=funpack_scenes_{key or 'global'}.json",
},
)
@PromptServer.instance.routes.post("/funpack/scenes/scene")
async def funpack_scene_save(request):
key = normalize_refinement_key(request.query.get("key", ""))
body = await request.json()
if not isinstance(body, dict):
return web.json_response({"error": "Scene payload must be an object."}, status=400)
action = str(body.get("action") or "save").lower()
name = normalize_scene_name(body.get("name"))
if not name:
return web.json_response({"error": "Scene name is required."}, status=400)
data = load_scene_db(key)
scenes = data.setdefault("scenes", {})
if action == "delete":
scenes.pop(name, None)
save_scene_db(data, key)
return web.json_response({"deleted": name, "scenes": scene_names(key), "data": data})
previous = scenes.get(name, {}) if isinstance(scenes.get(name), dict) else {}
scene = scene_from_texts(
name,
body.get("aliases", previous.get("aliases", [])),
body.get("mode") or body.get("output_mode") or previous.get("output_mode", "Manual"),
body.get("positive_text", previous.get("positive_text", "")),
body.get("negative_text", previous.get("negative_text", "")),
key,
)
scene["created_at"] = previous.get("created_at", now_iso())
scene["updated_at"] = now_iso()
scenes[name] = scene
save_scene_db(data, key)
return web.json_response({"saved": name, "scenes": scene_names(key), "data": data})
@PromptServer.instance.routes.post("/funpack/scenes/database")
async def funpack_scene_database_save(request):
key = normalize_refinement_key(request.query.get("key", ""))
body = await request.json()
if not isinstance(body, dict):
return web.json_response({"error": "Database payload must be an object."}, status=400)
incoming = body.get("universal_memory", body.get("memory", {}))
memory = normalize_scene_memory_items(incoming)
data = load_scene_db(key)
memory = apply_scene_database_authority(data.get("universal_memory", {}), memory)
data["universal_memory"] = memory
save_scene_db(data, key)
return web.json_response({"saved": len(memory), "data": data, "memory": scene_memory_items(data)})
@PromptServer.instance.routes.post("/funpack/scenes/import")
async def funpack_scenes_import(request):
key = normalize_refinement_key(request.query.get("key", ""))
incoming = await request.json()
if not isinstance(incoming, dict):
return web.json_response({"error": "Imported file is not a scene database."}, status=400)
data = load_scene_db(key)
imported_memory = incoming.get("universal_memory", {})
if isinstance(imported_memory, dict):
memory = data.setdefault("universal_memory", {})
for memory_key, item in imported_memory.items():
if isinstance(item, dict):
memory[str(memory_key)] = item
imported_scenes = incoming.get("scenes", {})
if not isinstance(imported_scenes, dict):
return web.json_response({"error": "Imported file does not contain a scenes object."}, status=400)
scenes = data.setdefault("scenes", {})
imported = 0
for name, scene in imported_scenes.items():
clean_name = normalize_scene_name(name)
if not clean_name or not isinstance(scene, dict):
continue
item = dict(scene)
item["name"] = clean_name
item["updated_at"] = now_iso()
scenes[clean_name] = item
imported += 1
save_scene_db(data, key)
return web.json_response({"imported": imported, "scenes": scene_names(key), "refinement_key": key})
@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),
},
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)})
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)})
@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)})
@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_transitions.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=funpack_refinement_{loaded_key}.json",
},
)
@PromptServer.instance.routes.post("/funpack/refinement_keys/import")
async def funpack_refinement_keys_import(request):
incoming = await request.json()
if isinstance(incoming, dict) and isinstance(incoming.get("state"), dict):
incoming = incoming["state"]
state, key = normalize_refinement_state(incoming)
if state is None:
return web.json_response({"error": "Imported file is not a valid V2 refinement key JSON."}, status=400)
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.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)
class FunPackSceneBuilder:
CATEGORY = "FunPack/Scene"
RETURN_TYPES = ("STRING", "STRING", "FUNPACK_LORA_STACK", "STRING")
RETURN_NAMES = (
"positive_prompt",
"negative_prompt",
"lora_stack",
"status",
)
FUNCTION = "build_scene"
OUTPUT_NODE = True
DESCRIPTION = "Builds named scene presets from manually selected universal prompt phrases."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"scene_name": ("STRING", {"default": "", "multiline": False}),
"mode": (["Manual", "Auto", "Learning"], {"default": "Manual"}),
"scene": (scene_names(), {"default": SCENE_NONE}),
"aliases": ("STRING", {"default": "", "multiline": False}),
"action": (["load", "save", "update", "delete"], {"default": "load"}),
"scene_positive": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Write or arrange positive scene words and phrases here.",
}),
"scene_negative": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Write or arrange negative scene words and phrases here.",
}),
"refinement_key": ("STRING", {"default": "", "multiline": False}),
"scene_payload": ("STRING", {"default": "{}", "multiline": False}),
},
"optional": {
"intent_prompt": ("STRING", {
"default": "",
"multiline": True,
"forceInput": True,
"tooltip": "Connected intent text used for Auto scene detection.",
}),
"positive_prompt": ("STRING", {
"default": "",
"multiline": True,
"forceInput": True,
"tooltip": "Connected positive prompt used only to collect universal scene phrase memory.",
}),
"negative_prompt": ("STRING", {
"default": "",
"multiline": True,
"forceInput": True,
"tooltip": "Connected negative prompt used only to collect universal negative phrase memory.",
}),
"refinement_key_input": ("STRING", {
"default": "",
"multiline": False,
"forceInput": True,
"tooltip": "Optional linked refinement key, for example from FunPack Refinement Key Loader.",
}),
"lora_stack": ("FUNPACK_LORA_STACK", {
"tooltip": "Optional current LoRA stack. Scene Builder passes it through unchanged so Refiner can use it for suggestions.",
}),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def VALIDATE_INPUTS(cls, scene=None, **kwargs):
return True
def _manual_scene(self, selected_name, scene_name, aliases, mode, scene_positive, scene_negative, refinement_key, payload):
target_name = normalize_scene_name(scene_name) or selected_name
positive_text = scene_text_or_payload(scene_positive, payload["positive_phrases"])
negative_text = scene_text_or_payload(scene_negative, payload["negative_phrases"])
return scene_from_texts(target_name, aliases, mode, positive_text, negative_text, refinement_key)
def _shortcut_seed(self, refinement_key, prompt):
key = f"{normalize_refinement_key(refinement_key)}::{str(prompt or '')}"
return int(md5(key.encode("utf-8")).hexdigest()[:12], 16)
def _shortcut_status(self, applied):
if not applied:
return "Shortcuts: none."
labels = []
for item in applied[:8]:
labels.append(f"{item.get('trigger', '')}->{item.get('replacement', '')}")
suffix = f", ... ({len(applied)} total)" if len(applied) > 8 else ""
return f"Shortcuts: expanded {len(applied)} activation(s): {', '.join(labels)}{suffix}."
def _outputs_for_scene(self, name, scene, scene_db=None, lora_stack=None, source="Manual"):
positive = scene_prompt_text(scene, "positive_text", "positive_phrases")
negative = scene_prompt_text(scene, "negative_text", "negative_phrases")
positive, shortcut_applied = apply_prompt_shortcuts(
positive,
seed=self._shortcut_seed(scene.get("refinement_key", "") if isinstance(scene, dict) else "", positive),
)
positive = resolve_scene_database_wildcards(positive, scene_db)
negative = resolve_scene_database_wildcards(negative, scene_db)
lora_count = len(lora_stack.get("loras", [])) if isinstance(lora_stack, dict) else 0
status = (
f"Scene Builder {source}: '{name or scene.get('name', '') or 'unsaved'}'. "
f"Stored fields: {scene_field_summary(scene)}.\n"
f"Positive phrases: {len(scene.get('positive_phrases', []) or [])}. "
f"Negative phrases: {len(scene.get('negative_phrases', []) or [])}. "
f"LoRA stack pass-through: {lora_count} LoRA(s).\n"
f"{self._shortcut_status(shortcut_applied)}"
)
return (positive, negative, lora_stack, status)
def _learning_outputs(self, positive_prompt, negative_prompt, lora_stack=None, refinement_key=""):
positive, shortcut_applied = apply_prompt_shortcuts(
str(positive_prompt or ""),
seed=self._shortcut_seed(refinement_key, positive_prompt),
)
negative = str(negative_prompt or "")
lora_count = len(lora_stack.get("loras", [])) if isinstance(lora_stack, dict) else 0
status = (
"Scene Builder Learning: pass-through active. "
f"Collected positive source: {'yes' if positive else 'no'}. "
f"Collected negative source: {'yes' if negative else 'no'}. "
f"LoRA stack pass-through: {lora_count} LoRA(s).\n"
f"{self._shortcut_status(shortcut_applied)}"
)
return (positive, negative, lora_stack, status)
def _empty_outputs(self, status):
return ("", "", None, status)
def build_scene(
self,
scene_name,
mode="Manual",
scene=SCENE_NONE,
aliases="",
action="load",
scene_positive="",
scene_negative="",
refinement_key="",
scene_payload="{}",
intent_prompt="",
positive_prompt="",
negative_prompt="",
refinement_key_input="",
lora_stack=None,
output_mode=None,
):
linked_key = normalize_refinement_key(refinement_key_input)
if linked_key:
refinement_key = linked_key
selected_name = normalize_scene_name(scene)
if output_mode in {"Manual", "Auto", "Learning"}:
mode = output_mode
mode = mode if mode in {"Manual", "Auto", "Learning"} else "Manual"
action = action if action in {"load", "save", "update", "delete"} else "load"
payload = parse_scene_payload(scene_payload)
data = load_scene_db(refinement_key)
memory_positive = "\n".join(
item for item in (
positive_prompt,
scene_positive,
scene_text_from_phrases(payload.get("positive_phrases", [])),
)
if str(item or "").strip()
)
memory_negative = "\n".join(
item for item in (
negative_prompt,
scene_negative,
scene_text_from_phrases(payload.get("negative_phrases", [])),
)
if str(item or "").strip()
)
memory_changed = remember_scene_phrases(data, memory_positive, memory_negative)
scenes = data.setdefault("scenes", {})
manual = self._manual_scene(
selected_name,
scene_name,
aliases,
mode,
scene_positive,
scene_negative,
refinement_key,
payload,
)
if action == "delete":
if not selected_name or selected_name not in scenes:
if memory_changed:
save_scene_db(data, refinement_key)
return self._empty_outputs("Delete skipped: no selected scene.")
deleted = scenes.pop(selected_name)
save_scene_db(data, refinement_key)
return self._empty_outputs(
f"Deleted scene '{selected_name}'. Removed fields: {scene_field_summary(deleted)}."
)
if action == "save":
target_name = normalize_scene_name(scene_name) or selected_name
if not target_name:
if memory_changed:
save_scene_db(data, refinement_key)
return self._empty_outputs("Save skipped: enter a scene name.")
manual["name"] = target_name
manual["created_at"] = scenes.get(target_name, {}).get("created_at", now_iso())
manual["updated_at"] = now_iso()
scenes[target_name] = manual
save_scene_db(data, refinement_key)
return self._outputs_for_scene(target_name, manual, data, lora_stack, "Manual saved")
if action == "update":
target_name = selected_name or normalize_scene_name(scene_name)
if not target_name:
if memory_changed:
save_scene_db(data, refinement_key)
return self._empty_outputs("Update skipped: select a scene or enter a scene name.")
previous = scenes.get(target_name, {})
manual["name"] = target_name
manual["created_at"] = previous.get("created_at", now_iso()) if isinstance(previous, dict) else now_iso()
manual["updated_at"] = now_iso()
scenes[target_name] = manual
save_scene_db(data, refinement_key)
return self._outputs_for_scene(target_name, manual, data, lora_stack, "Manual updated")
if mode == "Learning":
if memory_changed:
save_scene_db(data, refinement_key)
return self._learning_outputs(positive_prompt, negative_prompt, lora_stack, refinement_key)
if mode == "Auto":
matched_name, matched_scene, match_type = find_scene_for_intent(intent_prompt, scenes)
if matched_name and isinstance(matched_scene, dict):
if memory_changed:
save_scene_db(data, refinement_key)
return self._outputs_for_scene(matched_name, matched_scene, data, lora_stack, f"Auto {match_type}")
if action == "load" and selected_name and selected_name in scenes and not scene_positive and not scene_negative:
selected_scene = scenes.get(selected_name, {})
if memory_changed:
save_scene_db(data, refinement_key)
return self._outputs_for_scene(selected_name, selected_scene, data, lora_stack, "Manual loaded")
if memory_changed:
save_scene_db(data, refinement_key)
return self._outputs_for_scene(manual.get("name", ""), manual, data, lora_stack, "Manual")