Add global scene shortcuts
This commit is contained in:
+29
-1
@@ -5994,6 +5994,24 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
def _v2_prompt_key(self, prompt):
|
||||
return re.sub(r"\s+", " ", str(prompt or "").strip())
|
||||
|
||||
def _v2_apply_global_shortcuts(self, text, seed=0):
|
||||
try:
|
||||
try:
|
||||
from .templates import apply_prompt_shortcuts
|
||||
except ImportError:
|
||||
from templates import apply_prompt_shortcuts
|
||||
expanded, applied = apply_prompt_shortcuts(text, seed=seed)
|
||||
except Exception as error:
|
||||
return str(text or ""), f"Shortcuts: unavailable ({error}).", []
|
||||
if not applied:
|
||||
return str(text or ""), "Shortcuts: none.", []
|
||||
labels = [
|
||||
f"{item.get('trigger', '')}->{item.get('replacement', '')}"
|
||||
for item in applied[:8]
|
||||
]
|
||||
suffix = f", ... ({len(applied)} total)" if len(applied) > 8 else ""
|
||||
return expanded, f"Shortcuts: expanded {len(applied)} activation(s): {', '.join(labels)}{suffix}.", applied
|
||||
|
||||
def _v2_extract_conditioning(self, encoded):
|
||||
if not isinstance(encoded, list) or not encoded:
|
||||
return None, {"pooled_output": None}
|
||||
@@ -10737,6 +10755,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
linked_refinement_key = str(refinement_key_input or "").strip()
|
||||
if linked_refinement_key:
|
||||
refinement_key = linked_refinement_key
|
||||
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(positive_prompt, seed=seed)
|
||||
user_intent_prompt, intent_shortcut_status, intent_shortcut_applied = self._v2_apply_global_shortcuts(user_intent_prompt, seed=seed)
|
||||
if intent_shortcut_applied:
|
||||
shortcut_status = (
|
||||
f"{shortcut_status}\nIntent {intent_shortcut_status}"
|
||||
if shortcut_applied else
|
||||
f"Intent {intent_shortcut_status}"
|
||||
)
|
||||
|
||||
rating_profile = normalize_refiner_v2_rating(rating)
|
||||
rating_label = rating_profile.get("label", str(rating))
|
||||
@@ -11266,7 +11292,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
adaptation_status,
|
||||
seed_status,
|
||||
]
|
||||
for line in (repair_status, advisor_status, intent_alignment_status, vision_status, lucky_status):
|
||||
for line in (shortcut_status, repair_status, advisor_status, intent_alignment_status, vision_status, lucky_status):
|
||||
if _active(line):
|
||||
status_lines.append(line)
|
||||
if perfect_freeze:
|
||||
@@ -11302,6 +11328,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
_active(intent_alignment_status),
|
||||
_active(perfect_repair_status),
|
||||
_active(repair_status),
|
||||
_active(shortcut_status),
|
||||
_active(advisor_status),
|
||||
_active(lucky_status),
|
||||
_active(wildcard_status),
|
||||
@@ -11312,6 +11339,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
_active(intent_alignment_status),
|
||||
_active(perfect_repair_status),
|
||||
_active(repair_status),
|
||||
_active(shortcut_status),
|
||||
_active(advisor_status),
|
||||
_active(lucky_status),
|
||||
_active(wildcard_status),
|
||||
|
||||
@@ -18,6 +18,8 @@ If no scene exists yet, opening the editor, prompt editor, or database first ask
|
||||
|
||||
**Database**: Add, edit, delete, search, categorize, and mark universal words and phrases as wildcards. Double-click a word or phrase to edit it inline, then use **OK** or **Cancel**.
|
||||
|
||||
**Shortcuts**: Add global activation phrases that expand into longer positive prompt text. Shortcuts are shared across refinement keys, can be imported/exported separately, and are not cleared by Refiner reset. Activation phrases replace only the matched word or phrase and preserve surrounding spaces and punctuation.
|
||||
|
||||
The editor refreshes the selected scene database before opening. When `refinement_key_input` is connected to a Refinement Key Loader, the editor reads that linked key so prompt memory appears in the same database where queue runs stored it.
|
||||
|
||||
Each editor menu has **Back**, **Cancel**, and **Confirm** controls. Back always returns to the main editor. Cancel restores the menu snapshot; if the prompt/database editor was opened directly from the node, Cancel closes the dialog instead.
|
||||
@@ -30,6 +32,8 @@ Each editor menu has **Back**, **Cancel**, and **Confirm** controls. Back always
|
||||
|
||||
**Learning** saves connected positive and negative prompt phrases and useful words into Scene Builder universal memory, then passes the connected positive prompt, negative prompt, and LoRA stack through unchanged.
|
||||
|
||||
Shortcuts apply to positive prompt output in all modes, including Learning pass-through. Negative prompts are never expanded.
|
||||
|
||||
## Connection-Only Inputs
|
||||
|
||||
**intent_prompt**: Text used for Auto scene detection. This can be the same intent prompt that feeds Refiner V2.
|
||||
|
||||
@@ -59,6 +59,12 @@ Available when Scene Builder mode is not Pass-through. Save and load named scene
|
||||
|
||||
When Scene Builder is active the Refiner tab shows a notice and the intent override field is disabled, since intent derives from the scene prompt.
|
||||
|
||||
### Shortcuts
|
||||
|
||||
Shortcuts are global positive-prompt expansions shared by Scene Builder, Studio, and Refiner V2. Each shortcut has one or more activation phrases and one or more replacement phrases. Matching uses exact word or phrase boundaries, preserves surrounding spaces and punctuation, and chooses among replacements with the run seed.
|
||||
|
||||
Shortcuts are stored outside refinement keys, can be imported/exported separately, and are not cleared by **Reset session**.
|
||||
|
||||
### Refiner
|
||||
|
||||
All Refiner V2 execution settings:
|
||||
|
||||
+295
-5
@@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from hashlib import md5
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import folder_paths
|
||||
@@ -71,6 +72,10 @@ 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 refinement_store_dir():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "refinements")
|
||||
|
||||
@@ -237,6 +242,14 @@ def empty_scene_db():
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
@@ -249,6 +262,195 @@ def normalize_scene_db(data):
|
||||
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_list(item.get("replacements", item.get("replacement", [])))
|
||||
if not name:
|
||||
name = triggers[0] if triggers else ""
|
||||
if not name or not triggers or not replacements:
|
||||
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 name, 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_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 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 = [item.strip() for item in shortcut_list(shortcut.get("replacements", [])) if item.strip()]
|
||||
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 = []
|
||||
|
||||
def replace(match):
|
||||
for index, (trigger, _, replacements, name) in enumerate(candidates):
|
||||
if match.group(f"t{index}") is None:
|
||||
continue
|
||||
replacement = rng.choice(replacements).strip()
|
||||
applied.append({"name": str(name), "trigger": trigger, "replacement": replacement})
|
||||
return replacement
|
||||
return match.group(0)
|
||||
|
||||
expanded = re.sub(combined, replace, original, flags=re.IGNORECASE | re.UNICODE)
|
||||
return expanded, applied
|
||||
|
||||
|
||||
def load_scene_db(refinement_key=""):
|
||||
key = normalize_refinement_key(refinement_key)
|
||||
if key:
|
||||
@@ -1051,6 +1253,72 @@ async def funpack_scenes_import(request):
|
||||
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/refinement_keys")
|
||||
async def funpack_refinement_keys(_):
|
||||
return web.json_response(
|
||||
@@ -1246,9 +1514,26 @@ class FunPackSceneBuilder:
|
||||
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
|
||||
@@ -1257,19 +1542,24 @@ class FunPackSceneBuilder:
|
||||
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)."
|
||||
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):
|
||||
positive = str(positive_prompt or "")
|
||||
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)."
|
||||
f"LoRA stack pass-through: {lora_count} LoRA(s).\n"
|
||||
f"{self._shortcut_status(shortcut_applied)}"
|
||||
)
|
||||
return (positive, negative, lora_stack, status)
|
||||
|
||||
@@ -1375,7 +1665,7 @@ class FunPackSceneBuilder:
|
||||
if mode == "Learning":
|
||||
if memory_changed:
|
||||
save_scene_db(data, refinement_key)
|
||||
return self._learning_outputs(positive_prompt, negative_prompt, lora_stack)
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,8 @@ import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
@@ -27,12 +29,17 @@ import templates
|
||||
from conditioning import FunPackVideoRefinerV2
|
||||
from templates import (
|
||||
FunPackSceneBuilder,
|
||||
apply_prompt_shortcuts,
|
||||
apply_scene_database_authority,
|
||||
extract_scene_phrases,
|
||||
load_shortcut_db,
|
||||
load_scene_db,
|
||||
normalize_scene_text_spacing,
|
||||
normalize_scene_memory_items,
|
||||
normalize_shortcut_db,
|
||||
save_shortcut_db,
|
||||
remember_scene_phrases,
|
||||
save_shortcut_item,
|
||||
save_scene_db,
|
||||
)
|
||||
|
||||
@@ -515,6 +522,161 @@ def test_scene_builder_database_items_keep_categories_and_wildcards():
|
||||
assert memory["bad hands"]["source"] == "negative"
|
||||
|
||||
|
||||
def test_shortcuts_are_global_and_independent_from_refinement_keys(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
|
||||
save_shortcut_item({
|
||||
"name": "Run",
|
||||
"triggers": ["running"],
|
||||
"replacements": ["running freely"],
|
||||
})
|
||||
|
||||
data = load_scene_db("some_key")
|
||||
assert data["scenes"] == {}
|
||||
shortcuts = load_shortcut_db()["shortcuts"]
|
||||
assert "run" in shortcuts
|
||||
assert shortcuts["run"]["triggers"] == ["running"]
|
||||
|
||||
|
||||
def test_refiner_reset_session_does_not_clear_global_shortcuts(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
save_shortcut_item({
|
||||
"name": "Run",
|
||||
"triggers": ["running"],
|
||||
"replacements": ["running freely"],
|
||||
})
|
||||
|
||||
FunPackVideoRefinerV2()._v2_load_state("reset_key", reset_session=True)
|
||||
|
||||
assert "run" in load_shortcut_db()["shortcuts"]
|
||||
|
||||
|
||||
def test_shortcuts_match_boundaries_and_preserve_surrounding_spacing(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
save_shortcut_item({
|
||||
"name": "Run",
|
||||
"triggers": ["running"],
|
||||
"replacements": ["running freely"],
|
||||
})
|
||||
|
||||
expanded, applied = apply_prompt_shortcuts(
|
||||
"A video of man running in the park, running.",
|
||||
seed=1,
|
||||
)
|
||||
|
||||
assert expanded == "A video of man running freely in the park, running freely."
|
||||
assert "manrunning" not in expanded
|
||||
assert "parkfreely" not in expanded
|
||||
assert len(applied) == 2
|
||||
|
||||
unchanged, applied = apply_prompt_shortcuts("A video of over-running shoes", seed=1)
|
||||
assert unchanged == "A video of over-running shoes"
|
||||
assert applied == []
|
||||
|
||||
|
||||
def test_shortcuts_use_longest_trigger_and_seeded_replacement(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
save_shortcut_item({
|
||||
"name": "Look",
|
||||
"triggers": ["camera", "looks into camera"],
|
||||
"replacements": [
|
||||
"a man is facing the camera and looking into it",
|
||||
"a man's gaze is directed at the viewer",
|
||||
],
|
||||
})
|
||||
|
||||
first, first_applied = apply_prompt_shortcuts("He looks into camera.", seed=77)
|
||||
second, second_applied = apply_prompt_shortcuts("He looks into camera.", seed=77)
|
||||
|
||||
assert first == second
|
||||
assert first_applied == second_applied
|
||||
assert first_applied[0]["trigger"] == "looks into camera"
|
||||
assert "He " in first
|
||||
assert first.endswith(".")
|
||||
|
||||
|
||||
def test_disabled_shortcuts_do_not_expand(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
save_shortcut_item({
|
||||
"name": "Run",
|
||||
"enabled": False,
|
||||
"triggers": ["running"],
|
||||
"replacements": ["running freely"],
|
||||
})
|
||||
|
||||
expanded, applied = apply_prompt_shortcuts("running", seed=1)
|
||||
|
||||
assert expanded == "running"
|
||||
assert applied == []
|
||||
|
||||
|
||||
def test_shortcut_only_import_export_shape_round_trips(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
exported = normalize_shortcut_db({
|
||||
"shortcuts": {
|
||||
"look": {
|
||||
"name": "Look",
|
||||
"enabled": True,
|
||||
"triggers": ["looks into camera"],
|
||||
"replacements": ["a man is facing the camera and looking into it"],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
save_shortcut_db(exported)
|
||||
|
||||
loaded = load_shortcut_db()
|
||||
assert loaded["shortcuts"]["look"]["name"] == "Look"
|
||||
assert loaded["shortcuts"]["look"]["triggers"] == ["looks into camera"]
|
||||
|
||||
|
||||
def test_scene_builder_expands_shortcuts_only_in_positive_prompt(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
save_shortcut_item({
|
||||
"name": "Run",
|
||||
"triggers": ["running"],
|
||||
"replacements": ["running freely"],
|
||||
})
|
||||
|
||||
positive, negative, _, status = FunPackSceneBuilder().build_scene(
|
||||
scene="-None-",
|
||||
scene_name="Shortcut Scene",
|
||||
aliases="",
|
||||
action="load",
|
||||
mode="Manual",
|
||||
scene_positive="A video of man running in the park",
|
||||
scene_negative="running",
|
||||
refinement_key="shortcut_key",
|
||||
)
|
||||
|
||||
assert positive == "A video of man running freely in the park"
|
||||
assert negative == "running"
|
||||
assert "Shortcuts: expanded" in status
|
||||
|
||||
|
||||
def test_refiner_expands_positive_and_intent_shortcuts_before_encoding(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
save_shortcut_item({
|
||||
"name": "Run",
|
||||
"triggers": ["running"],
|
||||
"replacements": ["running freely"],
|
||||
})
|
||||
|
||||
cond = [(torch.ones(1, 2, 4), {"pooled_output": torch.ones(1, 4)})]
|
||||
_, status, _, _, encoded_prompts, _, _ = FunPackVideoRefinerV2().refine_v2(
|
||||
"A video of man running",
|
||||
clip=None,
|
||||
rating="Missing action",
|
||||
refinement_key="refiner_shortcut_key",
|
||||
positive_conditioning=cond,
|
||||
user_intent_prompt="running",
|
||||
reset_session=True,
|
||||
)
|
||||
|
||||
assert "A video of man running freely" in encoded_prompts
|
||||
assert "Shortcuts: expanded" in status
|
||||
|
||||
|
||||
def test_scene_builder_wildcard_outputs_one_adjacent_matching_phrase(monkeypatch, tmp_path):
|
||||
use_tmp_scene_store(monkeypatch, tmp_path)
|
||||
builder = FunPackSceneBuilder()
|
||||
|
||||
@@ -18,6 +18,7 @@ const HIDDEN_WIDGETS = new Set([
|
||||
const GROUP_ORDER = ["subject", "appearance", "action", "camera", "environment", "style", "quality", "details", "negative"];
|
||||
|
||||
let sceneData = null;
|
||||
let shortcutData = null;
|
||||
let activePanel = null;
|
||||
const trackedNodes = new Set();
|
||||
|
||||
@@ -185,6 +186,10 @@ function memoryItems() {
|
||||
return Array.isArray(sceneData?.memory) ? sceneData.memory : [];
|
||||
}
|
||||
|
||||
function shortcutItems() {
|
||||
return Array.isArray(shortcutData?.shortcuts) ? shortcutData.shortcuts : [];
|
||||
}
|
||||
|
||||
function normalizeMemoryMap(items) {
|
||||
const output = {};
|
||||
for (const item of items || []) {
|
||||
@@ -225,6 +230,20 @@ async function fetchScenes(refinementKey = "") {
|
||||
return sceneData;
|
||||
}
|
||||
|
||||
async function fetchShortcuts() {
|
||||
try {
|
||||
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
|
||||
const response = await api.fetchApi(`/funpack/shortcuts?${params.toString()}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
shortcutData = await response.json();
|
||||
} catch (error) {
|
||||
console.warn("FunPack: failed to refresh shortcuts", error);
|
||||
}
|
||||
return shortcutData;
|
||||
}
|
||||
|
||||
function updateSceneCombo(node) {
|
||||
const widget = widgetByName(node, "scene");
|
||||
if (!widget) {
|
||||
@@ -239,7 +258,7 @@ function updateSceneCombo(node) {
|
||||
}
|
||||
|
||||
async function refreshNode(node) {
|
||||
await fetchScenes(currentRefinementKey(node));
|
||||
await Promise.all([fetchScenes(currentRefinementKey(node)), fetchShortcuts()]);
|
||||
updateSceneCombo(node);
|
||||
setDirty(node);
|
||||
}
|
||||
@@ -346,6 +365,80 @@ async function saveDatabase(node, items) {
|
||||
await refreshNode(node);
|
||||
}
|
||||
|
||||
async function saveShortcut(item) {
|
||||
const response = await api.fetchApi("/funpack/shortcuts/shortcut", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "save", ...item }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || `Shortcut save failed with HTTP ${response.status}`);
|
||||
}
|
||||
shortcutData = await response.json();
|
||||
return shortcutData;
|
||||
}
|
||||
|
||||
async function deleteShortcut(name) {
|
||||
const response = await api.fetchApi("/funpack/shortcuts/shortcut", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || `Shortcut delete failed with HTTP ${response.status}`);
|
||||
}
|
||||
shortcutData = await response.json();
|
||||
return shortcutData;
|
||||
}
|
||||
|
||||
async function exportShortcuts() {
|
||||
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
|
||||
const response = await api.fetchApi(`/funpack/shortcuts/export?${params.toString()}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Shortcut export failed with HTTP ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "funpack_shortcuts.json";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function importShortcuts(onDone) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json,application/json";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
const response = await api.fetchApi("/funpack/shortcuts/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || `Shortcut import failed with HTTP ${response.status}`);
|
||||
}
|
||||
shortcutData = await response.json();
|
||||
onDone?.();
|
||||
} catch (error) {
|
||||
showPanelError(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function exportScenes(node) {
|
||||
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
|
||||
const key = currentRefinementKey(node);
|
||||
@@ -1104,6 +1197,8 @@ function renderPanel(panel, node, view, options = {}) {
|
||||
renderPromptEditor(panel, node, view);
|
||||
} else if (view === "database") {
|
||||
renderDatabaseEditor(panel, node);
|
||||
} else if (view === "shortcuts") {
|
||||
renderShortcutEditor(panel, node);
|
||||
} else if (view === "create") {
|
||||
renderCreateScene(panel, node, options.nextView || "menu");
|
||||
} else {
|
||||
@@ -1243,7 +1338,7 @@ function renderMenu(panel, node) {
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "funpack-scene-menu-buttons";
|
||||
for (const [label, target] of [["Positive prompt", "positive"], ["Negative prompt", "negative"], ["Database", "database"]]) {
|
||||
for (const [label, target] of [["Positive prompt", "positive"], ["Negative prompt", "negative"], ["Database", "database"], ["Shortcuts", "shortcuts"]]) {
|
||||
const item = panelButton(label, "large");
|
||||
item.addEventListener("click", () => navigatePanel(panel, node, target, { cancelTarget: "menu" }));
|
||||
menu.append(item);
|
||||
@@ -1682,6 +1777,141 @@ function renderDatabaseEditor(panel, node) {
|
||||
body.append(footer);
|
||||
}
|
||||
|
||||
function splitLines(value) {
|
||||
return String(value || "")
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function renderShortcutEditor(panel, node) {
|
||||
const body = shell(panel.root, "Shortcuts", "shortcuts");
|
||||
|
||||
const tools = document.createElement("div");
|
||||
tools.className = "funpack-scene-shortcut-tools";
|
||||
const add = panelButton("Add shortcut", "primary");
|
||||
add.addEventListener("click", () => {
|
||||
panel.shortcutDrafts = panel.shortcutDrafts || JSON.parse(JSON.stringify(shortcutItems()));
|
||||
panel.shortcutDrafts.unshift({
|
||||
name: "",
|
||||
enabled: true,
|
||||
triggers: [],
|
||||
replacements: [],
|
||||
});
|
||||
renderPanel(panel, node, "shortcuts");
|
||||
});
|
||||
const refresh = panelButton("Refresh");
|
||||
refresh.addEventListener("click", async () => {
|
||||
await fetchShortcuts();
|
||||
delete panel.shortcutDrafts;
|
||||
renderPanel(panel, node, "shortcuts");
|
||||
});
|
||||
const imp = panelButton("Import");
|
||||
imp.addEventListener("click", () => importShortcuts(() => {
|
||||
delete panel.shortcutDrafts;
|
||||
renderPanel(panel, node, "shortcuts");
|
||||
}));
|
||||
const exp = panelButton("Export");
|
||||
exp.addEventListener("click", async () => {
|
||||
try {
|
||||
await exportShortcuts();
|
||||
} catch (error) {
|
||||
showPanelError(error);
|
||||
}
|
||||
});
|
||||
tools.append(add, refresh, imp, exp);
|
||||
body.append(tools);
|
||||
|
||||
if (!panel.shortcutDrafts) {
|
||||
panel.shortcutDrafts = JSON.parse(JSON.stringify(shortcutItems()));
|
||||
}
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "funpack-scene-shortcut-list";
|
||||
body.append(list);
|
||||
|
||||
const drafts = panel.shortcutDrafts;
|
||||
if (!drafts.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "funpack-scene-muted";
|
||||
empty.textContent = "No shortcuts yet.";
|
||||
list.append(empty);
|
||||
}
|
||||
|
||||
drafts.forEach((item, index) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "funpack-scene-shortcut-row";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "funpack-scene-shortcut-top";
|
||||
const name = panelTextInput(item.name || "", "Shortcut name");
|
||||
name.addEventListener("input", () => { item.name = name.value; });
|
||||
const enabled = panelCheckbox(item.enabled !== false, "Enabled");
|
||||
enabled.input.addEventListener("change", () => { item.enabled = enabled.input.checked; });
|
||||
top.append(name, enabled.wrapper);
|
||||
|
||||
const triggers = document.createElement("textarea");
|
||||
triggers.className = "funpack-scene-edit-textarea";
|
||||
triggers.placeholder = "Activation phrases, one per line";
|
||||
triggers.value = (item.triggers || []).join("\n");
|
||||
triggers.addEventListener("input", () => { item.triggers = splitLines(triggers.value); });
|
||||
|
||||
const replacements = document.createElement("textarea");
|
||||
replacements.className = "funpack-scene-edit-textarea";
|
||||
replacements.placeholder = "Replacement phrases, one per line";
|
||||
replacements.value = (item.replacements || []).join("\n");
|
||||
replacements.addEventListener("input", () => { item.replacements = splitLines(replacements.value); });
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "funpack-scene-shortcut-actions";
|
||||
const save = panelButton("Save", "primary");
|
||||
save.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveShortcut({
|
||||
original_name: item.key || item.name,
|
||||
name: item.name,
|
||||
enabled: item.enabled !== false,
|
||||
triggers: splitLines(triggers.value),
|
||||
replacements: splitLines(replacements.value),
|
||||
});
|
||||
await fetchShortcuts();
|
||||
delete panel.shortcutDrafts;
|
||||
renderPanel(panel, node, "shortcuts");
|
||||
} catch (error) {
|
||||
showPanelError(error);
|
||||
}
|
||||
});
|
||||
const del = panelButton("Delete", "danger");
|
||||
del.addEventListener("click", async () => {
|
||||
try {
|
||||
if (item.key || item.name) {
|
||||
await deleteShortcut(item.name || item.key);
|
||||
await fetchShortcuts();
|
||||
}
|
||||
drafts.splice(index, 1);
|
||||
delete panel.shortcutDrafts;
|
||||
renderPanel(panel, node, "shortcuts");
|
||||
} catch (error) {
|
||||
showPanelError(error);
|
||||
}
|
||||
});
|
||||
actions.append(save, del);
|
||||
|
||||
row.append(top, triggers, replacements, actions);
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "funpack-scene-footer";
|
||||
const back = panelButton("Back");
|
||||
back.addEventListener("click", () => {
|
||||
delete panel.shortcutDrafts;
|
||||
renderPanel(panel, node, "menu");
|
||||
});
|
||||
footer.append(back);
|
||||
body.append(footer);
|
||||
}
|
||||
|
||||
function openPanel(node, view = "menu") {
|
||||
closePanel();
|
||||
injectStyles();
|
||||
@@ -2053,6 +2283,35 @@ function injectStyles() {
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.funpack-scene-shortcut-tools,
|
||||
.funpack-scene-shortcut-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.funpack-scene-shortcut-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 430px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
.funpack-scene-shortcut-row {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.035);
|
||||
}
|
||||
.funpack-scene-shortcut-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.funpack-scene-footer {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
|
||||
+214
-3
@@ -11,7 +11,7 @@ const ADVISOR_MODES = ["Off", "Only diagnostics", "Only prompt", "Full"];
|
||||
const TEMPORAL_STYLES = ["natural", "accelerate", "decelerate", "loop", "freeze"];
|
||||
const SB_MODES = ["Pass-through", "Manual", "Auto", "Learning"];
|
||||
const CATEGORY_ORDER = ["action", "camera", "subject", "appearance", "environment", "style", "quality", "details"];
|
||||
const TABS = ["Session", "Scene", "Refiner", "Advisor", "LoRA", "Sampler", "Adjustments"];
|
||||
const TABS = ["Session", "Scene", "Shortcuts", "Refiner", "Advisor", "LoRA", "Sampler", "Adjustments"];
|
||||
const SAMPLER_TYPES = ["Hybrid Euler 2S", "Distilled Flow", "KSampler"];
|
||||
const MOTION_PULSE_MODES = ["off", "balanced", "aggressive", "custom"];
|
||||
const VELOCITY_BIAS_MODES = ["off", "capture", "apply", "capture_and_apply"];
|
||||
@@ -19,6 +19,8 @@ const KSAMPLER_NAMES = ["euler", "euler_ancestral", "dpm_2", "dpm_2_ancestral",
|
||||
|
||||
let activePanel = null;
|
||||
let studioSceneData = null;
|
||||
let studioSceneKey = null;
|
||||
let studioShortcutData = null;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,6 +124,7 @@ async function fetchScenes(key = "") {
|
||||
const res = await api.fetchApi(`/funpack/scenes?${params}`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
studioSceneData = await res.json();
|
||||
studioSceneKey = key || "";
|
||||
return studioSceneData;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -135,6 +138,74 @@ async function saveScene(key, payload) {
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
async function fetchShortcuts() {
|
||||
try {
|
||||
const params = new URLSearchParams({ cache_bust: Date.now() });
|
||||
const res = await api.fetchApi(`/funpack/shortcuts?${params}`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
studioShortcutData = await res.json();
|
||||
return studioShortcutData;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function saveShortcut(payload) {
|
||||
const res = await api.fetchApi("/funpack/shortcuts/shortcut", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "save", ...payload }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
|
||||
studioShortcutData = await res.json();
|
||||
return studioShortcutData;
|
||||
}
|
||||
|
||||
async function deleteShortcut(name) {
|
||||
const res = await api.fetchApi("/funpack/shortcuts/shortcut", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", name }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
|
||||
studioShortcutData = await res.json();
|
||||
return studioShortcutData;
|
||||
}
|
||||
|
||||
async function exportShortcuts() {
|
||||
const params = new URLSearchParams({ cache_bust: Date.now() });
|
||||
const res = await api.fetchApi(`/funpack/shortcuts/export?${params}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "funpack_shortcuts.json";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function importShortcuts(onDone, onError) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json,application/json";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
const res = await api.fetchApi("/funpack/shortcuts/import", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
|
||||
studioShortcutData = await res.json();
|
||||
onDone?.();
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function fetchLoras() {
|
||||
try {
|
||||
const res = await api.fetchApi("/funpack/available_loras", { cache: "no-store" });
|
||||
@@ -215,6 +286,13 @@ function row(label, control, cls = "") {
|
||||
|
||||
function sectionTitle(text) { return el("div", "funpack-studio-section-title", text); }
|
||||
|
||||
function splitLines(value) {
|
||||
return String(value || "")
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function closePanel() { activePanel?.remove(); activePanel = null; }
|
||||
@@ -283,6 +361,7 @@ function openPanel(node) {
|
||||
errorEl.textContent = "";
|
||||
if (name === "Session") renderSession();
|
||||
else if (name === "Scene") renderScene();
|
||||
else if (name === "Shortcuts") renderShortcuts();
|
||||
else if (name === "Refiner") renderRefiner();
|
||||
else if (name === "Advisor") renderAdvisor();
|
||||
else if (name === "LoRA") renderLora();
|
||||
@@ -295,7 +374,11 @@ function openPanel(node) {
|
||||
body.append(sectionTitle("Refinement Session"));
|
||||
|
||||
const keyInput = textInput(settings.refinement_key, "session key name");
|
||||
keyInput.addEventListener("input", () => { settings.refinement_key = keyInput.value.trim(); });
|
||||
keyInput.addEventListener("input", () => {
|
||||
settings.refinement_key = keyInput.value.trim();
|
||||
studioSceneData = null;
|
||||
studioSceneKey = null;
|
||||
});
|
||||
|
||||
const linkedKey = linkedRefinementKey(node);
|
||||
if (linkedKey) {
|
||||
@@ -317,6 +400,7 @@ function openPanel(node) {
|
||||
settings.scene_builder.mode = modeSelect.value;
|
||||
saveSettings(node, settings);
|
||||
studioSceneData = null;
|
||||
studioSceneKey = null;
|
||||
if (settings.scene_builder.mode !== "Pass-through") {
|
||||
await fetchScenes(settings.refinement_key || linkedRefinementKey(node));
|
||||
}
|
||||
@@ -428,12 +512,14 @@ function openPanel(node) {
|
||||
const footer = el("div", "funpack-studio-footer");
|
||||
const refreshBtn = btn("Refresh");
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
saveSettings(node, settings);
|
||||
await fetchScenes(key);
|
||||
renderScene();
|
||||
});
|
||||
const saveBtn = btn("Save scene", "primary");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
saveSettings(node, settings);
|
||||
await saveScene(key, {
|
||||
name: settings.scene_builder.scene_name,
|
||||
aliases: settings.scene_builder.aliases,
|
||||
@@ -442,6 +528,8 @@ function openPanel(node) {
|
||||
negative_text: settings.scene_builder.scene_negative,
|
||||
});
|
||||
await fetchScenes(key);
|
||||
settings.scene_builder.scene = settings.scene_builder.scene_name || settings.scene_builder.scene || NONE_SENTINEL;
|
||||
saveSettings(node, settings);
|
||||
renderScene();
|
||||
} catch (e) { showError(root, e.message); }
|
||||
});
|
||||
@@ -449,11 +537,110 @@ function openPanel(node) {
|
||||
body.append(footer);
|
||||
|
||||
// Fetch scenes if not loaded
|
||||
if (!studioSceneData) {
|
||||
if (!studioSceneData || studioSceneKey !== (key || "")) {
|
||||
fetchScenes(key).then(() => renderScene());
|
||||
}
|
||||
}
|
||||
|
||||
// SHORTCUTS ────────────────────────────────────────────────────────────────
|
||||
function renderShortcuts() {
|
||||
body.append(sectionTitle("Shortcuts"));
|
||||
if (!studioShortcutData) {
|
||||
body.append(el("div", "funpack-studio-empty", "Loading shortcuts..."));
|
||||
fetchShortcuts().then(() => renderTab("Shortcuts"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root.__shortcutDrafts) {
|
||||
const rows = Array.isArray(studioShortcutData?.shortcuts) ? studioShortcutData.shortcuts : [];
|
||||
root.__shortcutDrafts = JSON.parse(JSON.stringify(rows));
|
||||
}
|
||||
const drafts = root.__shortcutDrafts;
|
||||
|
||||
const toolbar = el("div", "funpack-studio-footer");
|
||||
const addBtn = btn("+ Add shortcut", "primary");
|
||||
addBtn.addEventListener("click", () => {
|
||||
drafts.unshift({ name: "", enabled: true, triggers: [], replacements: [] });
|
||||
renderTab("Shortcuts");
|
||||
});
|
||||
const refreshBtn = btn("Refresh");
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
await fetchShortcuts();
|
||||
delete root.__shortcutDrafts;
|
||||
renderTab("Shortcuts");
|
||||
});
|
||||
const importBtn = btn("Import");
|
||||
importBtn.addEventListener("click", () => importShortcuts(
|
||||
() => { delete root.__shortcutDrafts; renderTab("Shortcuts"); },
|
||||
(e) => showError(root, e.message),
|
||||
));
|
||||
const exportBtn = btn("Export");
|
||||
exportBtn.addEventListener("click", async () => {
|
||||
try { await exportShortcuts(); }
|
||||
catch (e) { showError(root, e.message); }
|
||||
});
|
||||
toolbar.append(addBtn, refreshBtn, importBtn, exportBtn);
|
||||
body.append(toolbar);
|
||||
|
||||
const list = el("div", "funpack-studio-shortcut-list");
|
||||
body.append(list);
|
||||
if (!drafts.length) {
|
||||
list.append(el("div", "funpack-studio-empty", "No shortcuts configured."));
|
||||
}
|
||||
|
||||
drafts.forEach((item, index) => {
|
||||
const rowEl = el("div", "funpack-studio-shortcut-row");
|
||||
const top = el("div", "funpack-studio-shortcut-top");
|
||||
const nameInput = textInput(item.name || "", "Shortcut name");
|
||||
nameInput.addEventListener("input", () => { item.name = nameInput.value; });
|
||||
const enabled = toggleEl(item.enabled !== false, "Enabled");
|
||||
enabled.inp.addEventListener("change", () => { item.enabled = enabled.inp.checked; });
|
||||
top.append(nameInput, enabled.wrap);
|
||||
|
||||
const triggerArea = el("textarea", "funpack-studio-textarea short");
|
||||
triggerArea.placeholder = "Activation phrases, one per line";
|
||||
triggerArea.value = (item.triggers || []).join("\n");
|
||||
triggerArea.addEventListener("input", () => { item.triggers = splitLines(triggerArea.value); });
|
||||
|
||||
const replacementArea = el("textarea", "funpack-studio-textarea short");
|
||||
replacementArea.placeholder = "Replacement phrases, one per line";
|
||||
replacementArea.value = (item.replacements || []).join("\n");
|
||||
replacementArea.addEventListener("input", () => { item.replacements = splitLines(replacementArea.value); });
|
||||
|
||||
const actions = el("div", "funpack-studio-shortcut-actions");
|
||||
const saveBtn = btn("Save", "primary");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveShortcut({
|
||||
original_name: item.key || item.name,
|
||||
name: item.name,
|
||||
enabled: item.enabled !== false,
|
||||
triggers: splitLines(triggerArea.value),
|
||||
replacements: splitLines(replacementArea.value),
|
||||
});
|
||||
await fetchShortcuts();
|
||||
delete root.__shortcutDrafts;
|
||||
renderTab("Shortcuts");
|
||||
} catch (e) { showError(root, e.message); }
|
||||
});
|
||||
const delBtn = btn("Delete", "danger");
|
||||
delBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (item.key || item.name) {
|
||||
await deleteShortcut(item.name || item.key);
|
||||
await fetchShortcuts();
|
||||
}
|
||||
drafts.splice(index, 1);
|
||||
delete root.__shortcutDrafts;
|
||||
renderTab("Shortcuts");
|
||||
} catch (e) { showError(root, e.message); }
|
||||
});
|
||||
actions.append(saveBtn, delBtn);
|
||||
rowEl.append(top, triggerArea, replacementArea, actions);
|
||||
list.append(rowEl);
|
||||
});
|
||||
}
|
||||
|
||||
// REFINER ──────────────────────────────────────────────────────────────────
|
||||
function renderRefiner() {
|
||||
const sbMode = settings.scene_builder?.mode || "Pass-through";
|
||||
@@ -956,6 +1143,30 @@ function injectStyles() {
|
||||
display: grid; grid-template-columns: minmax(0,1.8fr) 80px 54px 54px 28px;
|
||||
gap: 5px; align-items: center;
|
||||
}
|
||||
.funpack-studio-shortcut-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.funpack-studio-shortcut-row {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.035);
|
||||
}
|
||||
.funpack-studio-shortcut-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0,1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.funpack-studio-shortcut-actions {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lora-name, .lora-type { min-height: 28px; padding: 4px 6px;
|
||||
border: 1px solid rgba(180,190,200,0.28); border-radius: 5px;
|
||||
background: #17191d; color: #f2f2f2; outline: none; width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user