Add FunPack Scene Builder

This commit is contained in:
digital-garbage
2026-05-13 22:10:25 +03:00
parent 8305e44f2a
commit a38fea93b7
10 changed files with 1543 additions and 518 deletions
+4
View File
@@ -4,6 +4,8 @@
### Added
Added `FunPack Scene Builder`, a scene preset node that replaces `FunPack Template Manager`. It collects universal prompt phrase memory, lets users manually assign positive/negative scene phrases and LoRAs, and can auto-apply a saved scene from an intent prompt match.
Added searchable LoRA picking to `FunPack Apply LoRA Weights`. The compact row UI remains the primary workflow, and saved workflows still serialize through the existing `lora_list` JSON value.
Added optional `clip_vision_output`, `source_image`, and `negative_prompt` inputs to `FunPack Video Refiner V2`.
@@ -16,6 +18,8 @@ Added experimental early velocity bias capture/application controls to `FunPack
### Changed
Removed public registration for `FunPack Template Manager`. Use `FunPack Scene Builder` for new scene/preset workflows.
Updated V2 prompt repair so repaired phrases preserve stopwords and phrase text while still using filtered semantic tokens for matching and categorization.
Reduced repeated Refiner V2 CLIP model calls by caching category and phrase encodes within each run.
+3
View File
@@ -4,6 +4,8 @@ A set of ComfyUI nodes for experimenting with video generation workflows based o
## Updates in 2.3.4
Added `FunPack Scene Builder`, a replacement for `FunPack Template Manager`. It stores named scenes from manually selected universal prompt phrases, outputs scene LoRAs as a FunPack LoRA stack, and can auto-apply a scene when its name or alias appears in an intent prompt.
Added searchable LoRA selection to `FunPack Apply LoRA Weights` while keeping the compact row UI and serialized LoRA stack format.
Added advisory image/CLIP Vision context and repaired negative conditioning to `FunPack Video Refiner V2`. Vision inputs are stored as metadata and diagnostics only; they are not blended into positive conditioning.
@@ -106,6 +108,7 @@ Start with:
- [`docs/FunPackVideoRefinerV2.md`](docs/FunPackVideoRefinerV2.md) for `FunPack Video Refiner V2`
- [`docs/FunPackVideoRefinerV2QuickGuide.md`](docs/FunPackVideoRefinerV2QuickGuide.md) for a short Discord-friendly Refiner V2 guide
- [`docs/FunPackLoraWorkflow.md`](docs/FunPackLoraWorkflow.md) for the LoRA/refiner helper workflow
- [`docs/FunPackSceneBuilder.md`](docs/FunPackSceneBuilder.md) for scene preset workflows
Version history is available in [CHANGELOG.md](CHANGELOG.md).
+5 -5
View File
@@ -18,7 +18,7 @@ if __package__:
from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from .samplers import FunPackHybridEuler2SSampler
from .context_transition import FunPackContextTransitionWindows
from .templates import FunPackRefinementKeyLoader, FunPackTemplateManager
from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
else:
# Standalone tests may not have the full ComfyUI/CUDA runtime loaded.
from conditioning import (
@@ -56,10 +56,10 @@ else:
except Exception:
FunPackContextTransitionWindows = None
try:
from templates import FunPackRefinementKeyLoader, FunPackTemplateManager
from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
except Exception:
FunPackRefinementKeyLoader = None
FunPackTemplateManager = None
FunPackSceneBuilder = None
WEB_DIRECTORY = "./web"
@@ -79,7 +79,7 @@ NODE_CLASS_MAPPINGS = {
"FunPackApplyLoraWeights": FunPackApplyLoraWeights,
"FunPackLoraLoader": FunPackLoraLoader,
"FunPackRefinementKeyLoader": FunPackRefinementKeyLoader,
"FunPackTemplateManager": FunPackTemplateManager,
"FunPackSceneBuilder": FunPackSceneBuilder,
}
NODE_CLASS_MAPPINGS = {name: cls for name, cls in NODE_CLASS_MAPPINGS.items() if cls is not None}
@@ -99,7 +99,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FunPackApplyLoraWeights": "FunPack Apply LoRA Weights",
"FunPackLoraLoader": "FunPack LoRA Loader",
"FunPackRefinementKeyLoader": "FunPack Refinement Key Loader",
"FunPackTemplateManager": "FunPack Template Manager",
"FunPackSceneBuilder": "FunPack Scene Builder",
}
__all__ = [
+57
View File
@@ -0,0 +1,57 @@
# FunPack Scene Builder
`FunPack Scene Builder` stores named scene presets from phrases the user manually selects. Connected positive and negative prompts are memory sources only: every queue run collects their phrases into universal memory, but the node outputs only the selected scene phrases or a saved scene preset.
## Modes
**Manual** outputs the current Scene Builder GUI selection: selected positive phrases, selected negative phrases, scene LoRAs, mode, per-block setting, sigmas, and refinement key.
**Auto** scans `intent_prompt` for a saved scene name or alias. Exact matches are preferred, then a conservative word-match fallback is used. If nothing matches, the node falls back to Manual output.
## Inputs
**scene**: Saved scene dropdown.
**scene_name**: Name used when saving a scene.
**aliases**: Comma-separated trigger names for Auto mode.
**output_mode**: `Manual` or `Auto`.
**intent_prompt**: Text used for Auto scene detection. This can be the same intent prompt that feeds Refiner V2.
**positive_prompt** and **negative_prompt**: Universal memory sources. They do not override scene output.
**mode**: Stored model namespace, currently `ltx2` or `wan`.
**per_block**: Stored LoRA loader per-block preference.
**refinement_key** / **refinement_key_input**: Optional Refiner V2 key. When present, Scene Builder also tries to load matching stored positive conditioning.
**sigmas**: Optional sigma schedule stored with saved scenes.
## Outputs
**positive_prompt**: Selected positive scene phrases joined with commas.
**negative_prompt**: Selected negative scene phrases joined with commas.
**scene_name**: The manual scene name or the Auto-matched saved scene.
**sigmas**: Stored scene sigmas, current connected sigmas, or an empty sigma tensor.
**positive_conditioning**: Best available conditioning from the scene refinement key, if any.
**lora_stack**: A `FUNPACK_LORA_STACK` that can connect directly to `FunPack LoRA Loader`.
**refinement_key**: The scene refinement key.
**status**: Summary of selected mode, matched scene, phrase counts, LoRA count, and conditioning lookup.
## Workflow
1. Connect or type positive and negative prompts, then queue once to collect phrase memory.
2. Select phrase chips from the universal phrase bank.
3. Add scene LoRAs and set mode/per-block/refinement key as needed.
4. Enter a scene name and press **Save**.
5. Switch to **Auto** and write a saved scene name or alias into `intent_prompt` to apply that preset automatically.
-63
View File
@@ -1,63 +0,0 @@
# FunPack Template Manager
`FunPack Template Manager` stores reusable generation concept presets. Normal workflow runs are passive: the node loads the selected template and outputs its stored values. Save, Update, Delete, Import, Export, and Refresh are explicit node buttons.
## Inputs
**template**: Saved template dropdown. Refresh updates this list.
**name**: New template name. This is only used by Save when the name is unique. If the name already exists or is empty, Save targets the selected dropdown template.
**mode**: Stored workflow namespace. Refiner V2 conditioning lookup ignores this value, while legacy refiner fallback still uses it.
**wildcard_seed**: Seed for resolving `{option A|option B}` groups in the stored positive prompt. `0` uses random choices on each execution.
**activation_word**: Optional trigger or concept word stored with the template.
**refinement_key**: Optional `FunPack Video Refiner V2` key. When stored, the node outputs this key and tries to load the best saved positive conditioning from the matching refiner state.
**positive_prompt**: Optional prompt stored with the template. Supports wildcard groups such as `{wide shot|close-up}`.
**negative_prompt**: Optional negative prompt stored with the template.
**sigmas**: Optional sigma schedule stored with the template.
**lora_stack**: Optional stack from `FunPack Apply LoRA Weights`.
## Buttons
**Save** creates a new template when `name` is unique. Otherwise, it fully replaces the selected template with the currently provided fields.
**Update** updates only fields currently provided to the node. Existing stored fields not provided in this run are left untouched.
**Delete** removes the selected template.
**Export** downloads all templates as `funpack_templates.json`.
**Import** loads a previously exported JSON file and merges its templates into the local store. Templates with the same name are replaced.
**Refresh** reloads the template dropdown. ComfyUI's regular refresh also updates the list.
## Outputs
**positive_prompt**: Stored positive prompt with wildcard groups resolved.
**negative_prompt**: Stored negative prompt.
**activation_word**: Stored activation word, or an empty string.
**sigmas**: Stored sigmas, or an empty sigma tensor.
**positive_conditioning**: Best available conditioning from the stored refinement key. The node prefers the refiner's liked average when present, then the best-rated history item, then the stored reference.
**lora_stack**: Stored FunPack LoRA stack, or no stack.
**refinement_key**: Stored refinement key, or an empty string. If no key is stored, conditioning is not loaded.
**status**: Summary of the selected action, stored fields, wildcard seed, and refiner-conditioning lookup.
## Notes
Save is the way to intentionally remove fields from an existing template: leave unwanted fields empty or disconnected, then press Save on the selected template.
Update is additive and conservative. Empty strings and disconnected optional inputs are ignored so existing template data is not accidentally cleared.
+5 -5
View File
@@ -19,7 +19,7 @@ try:
from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from .samplers import FunPackHybridEuler2SSampler, sample_funpack_hybrid_euler_2s
from .context_transition import FunPackContextTransitionWindows
from .templates import FunPackRefinementKeyLoader, FunPackTemplateManager
from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
except ImportError:
from conditioning import (
FunPackLorebookEnhancer,
@@ -38,7 +38,7 @@ except ImportError:
from model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from samplers import FunPackHybridEuler2SSampler, sample_funpack_hybrid_euler_2s
from context_transition import FunPackContextTransitionWindows
from templates import FunPackRefinementKeyLoader, FunPackTemplateManager
from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
NODE_CLASS_MAPPINGS = {
"FunPackPromptCombiner": FunPackPromptCombiner,
@@ -56,7 +56,7 @@ except ImportError:
"FunPackApplyLoraWeights": FunPackApplyLoraWeights,
"FunPackLoraLoader": FunPackLoraLoader,
"FunPackRefinementKeyLoader": FunPackRefinementKeyLoader,
"FunPackTemplateManager": FunPackTemplateManager,
"FunPackSceneBuilder": FunPackSceneBuilder,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -75,7 +75,7 @@ except ImportError:
"FunPackApplyLoraWeights": "FunPack Apply LoRA Weights",
"FunPackLoraLoader": "FunPack LoRA Loader",
"FunPackRefinementKeyLoader": "FunPack Refinement Key Loader",
"FunPackTemplateManager": "FunPack Template Manager",
"FunPackSceneBuilder": "FunPack Scene Builder",
}
__all__ = [
@@ -96,6 +96,6 @@ __all__ = [
"FunPackApplyLoraWeights",
"FunPackLoraLoader",
"FunPackRefinementKeyLoader",
"FunPackTemplateManager",
"FunPackSceneBuilder",
"sample_funpack_hybrid_euler_2s",
]
+563 -111
View File
@@ -4,6 +4,7 @@ import os
import random
import re
from datetime import datetime, timezone
from hashlib import md5
import folder_paths
import torch
@@ -28,8 +29,22 @@ except ImportError:
TEMPLATE_NONE = "-None-"
REFINEMENT_KEY_NONE = "-None-"
SCENE_NONE = "-None-"
TEMPLATE_DB_VERSION = 1
SCENE_DB_VERSION = 1
WILDCARD_RE = re.compile(r"\{([^{}]*\|[^{}]*)\}")
SCENE_LORA_TYPES = {"general", "action", "style", "quality", "character"}
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"},
}
def template_store_dir():
@@ -47,6 +62,10 @@ 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 refinement_store_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "refinements")
@@ -204,6 +223,321 @@ def template_names():
return [TEMPLATE_NONE] + names
def empty_scene_db():
return {
"version": SCENE_DB_VERSION,
"source": "ComfyUI-FunPack",
"universal_memory": {},
"scenes": {},
}
def load_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()
if not isinstance(data, dict):
return 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 save_scene_db(data):
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():
data = load_scene_db()
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 scene_lora_type(value):
value = str(value or "general").strip().lower()
if value == "concept":
value = "action"
return value if value in SCENE_LORA_TYPES else "general"
def scene_lora_id(lora_name, lora_type):
return md5(f"{lora_name}::{lora_type}".encode("utf-8")).hexdigest()[:16]
def scene_prompt_key(prompt, mode):
del mode
return re.sub(r"\s+", " ", str(prompt or "").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"):
phrases = []
seen = set()
for raw in re.split(r"[,;\n]+", str(text or "")):
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,
})
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):
key = phrase["key"]
current = memory.setdefault(key, {
"text": phrase["text"],
"source": source,
"category": phrase["category"],
"tokens": phrase["tokens"],
"count": 0,
"created_at": now_iso(),
})
current["text"] = current.get("text") or phrase["text"]
current["source"] = source
current["category"] = phrase["category"]
current["tokens"] = phrase["tokens"]
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"),
"tokens": item.get("tokens", scene_token_words(text)),
"count": int(item.get("count", 0) or 0),
})
return sorted(items, key=lambda item: (item["category"], item["text"].lower()))
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 = re.sub(r"\s+", " ", str(text or "")).strip(" ,;:.").strip()
key = clean.lower()
if not clean or key in seen:
continue
seen.add(key)
result.append(clean)
return result
def normalize_scene_loras(value):
if isinstance(value, str):
try:
value = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
return []
if not isinstance(value, list):
return []
rows = []
for row in value:
if not isinstance(row, dict) or row.get("on", True) is False:
continue
name = str(row.get("lora") or row.get("name") or "None").strip()
if not name or name == "None":
continue
try:
strength = float(row.get("strength", row.get("base_model_weight", 1.0)))
except (TypeError, ValueError):
strength = 1.0
rows.append({
"on": True,
"lora": name,
"type": scene_lora_type(row.get("type", row.get("lora_type", "general"))),
"strength": strength,
})
return rows
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", [])),
"loras": normalize_scene_loras(payload.get("loras", [])),
}
def scene_payload_from_scene(scene):
if not isinstance(scene, dict):
return {"positive_phrases": [], "negative_phrases": [], "loras": []}
return {
"positive_phrases": normalize_scene_phrase_list(scene.get("positive_phrases", [])),
"negative_phrases": normalize_scene_phrase_list(scene.get("negative_phrases", [])),
"loras": normalize_scene_loras(scene.get("loras", [])),
}
def scene_text_from_phrases(phrases):
return ", ".join(normalize_scene_phrase_list(phrases))
def aliases_from_text(value):
aliases = []
seen = set()
for raw in re.split(r"[,;\n]+", str(value or "")):
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", "loras", "refinement_key", "sigmas"):
if scene.get(field):
fields.append(field)
return ", ".join(fields) if fields else "none"
def normalize_template_name(value):
value = str(value or "").strip()
if not value or value == TEMPLATE_NONE:
@@ -437,6 +771,65 @@ async def funpack_templates_import(request):
return web.json_response({"imported": imported, "templates": template_names()})
@PromptServer.instance.routes.get("/funpack/scenes")
async def funpack_scenes(_):
data = load_scene_db()
return web.json_response(
{
"scenes": scene_names(),
"path": scene_store_path(),
"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(_):
data = load_scene_db()
return web.json_response(
data,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": "attachment; filename=funpack_scenes.json",
},
)
@PromptServer.instance.routes.post("/funpack/scenes/import")
async def funpack_scenes_import(request):
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()
imported_memory = incoming.get("universal_memory", {})
if isinstance(imported_memory, dict):
memory = data.setdefault("universal_memory", {})
for key, item in imported_memory.items():
if isinstance(item, dict):
memory[str(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)
return web.json_response({"imported": imported, "scenes": scene_names()})
@PromptServer.instance.routes.get("/funpack/refinement_keys")
async def funpack_refinement_keys(_):
return web.json_response(
@@ -510,40 +903,48 @@ class FunPackRefinementKeyLoader:
return (loaded_key if loaded_key else target, status)
class FunPackTemplateManager:
CATEGORY = "FunPack/Templates"
class FunPackSceneBuilder:
CATEGORY = "FunPack/Scene"
RETURN_TYPES = ("STRING", "STRING", "STRING", "SIGMAS", "CONDITIONING", "FUNPACK_LORA_STACK", "STRING", "STRING")
RETURN_NAMES = (
"positive_prompt",
"negative_prompt",
"activation_word",
"scene_name",
"sigmas",
"positive_conditioning",
"lora_stack",
"refinement_key",
"status",
)
FUNCTION = "manage_template"
FUNCTION = "build_scene"
OUTPUT_NODE = True
DESCRIPTION = "Stores and loads reusable FunPack generation templates."
DESCRIPTION = "Builds named scene presets from manually selected universal prompt phrases and scene LoRAs."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"template": (template_names(), {"default": TEMPLATE_NONE}),
"name": ("STRING", {"default": "", "multiline": False}),
"scene": (scene_names(), {"default": SCENE_NONE}),
"scene_name": ("STRING", {"default": "", "multiline": False}),
"aliases": ("STRING", {"default": "", "multiline": False}),
"action": (["load", "save", "update", "delete"], {"default": "load"}),
"mode": (["ltx2", "wan"], {"default": "ltx2"}),
"wildcard_seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
"optional": {
"activation_word": ("STRING", {"default": "", "multiline": False}),
"refinement_key": ("STRING", {"default": "", "multiline": False}),
"output_mode": (["Manual", "Auto"], {"default": "Manual"}),
"intent_prompt": ("STRING", {"default": "", "multiline": True}),
"positive_prompt": ("STRING", {"default": "", "multiline": True}),
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
"mode": (["ltx2", "wan"], {"default": "ltx2"}),
"per_block": ("BOOLEAN", {"default": False}),
"refinement_key": ("STRING", {"default": "", "multiline": False}),
"scene_payload": ("STRING", {"default": "{}", "multiline": False}),
},
"optional": {
"refinement_key_input": ("STRING", {
"default": "",
"multiline": False,
"forceInput": True,
"tooltip": "Optional linked refinement key, for example from FunPack Refinement Key Loader.",
}),
"sigmas": ("SIGMAS",),
"lora_stack": ("FUNPACK_LORA_STACK",),
},
}
@@ -552,124 +953,175 @@ class FunPackTemplateManager:
return float("nan")
@classmethod
def VALIDATE_INPUTS(cls, template=None, **kwargs):
def VALIDATE_INPUTS(cls, scene=None, **kwargs):
return True
def _empty_outputs(self, status):
return ("", "", "", torch.FloatTensor([]), None, None, "", status)
def _empty_sigmas(self):
return torch.FloatTensor([])
def _loaded_outputs(self, name, template, wildcard_seed):
mode = template.get("mode", "ltx2")
positive_raw = template.get("positive_prompt", "")
positive_prompt = resolve_wildcards(positive_raw, wildcard_seed)
negative_prompt = template.get("negative_prompt", "")
activation_word = template.get("activation_word", "")
refinement_key = template.get("refinement_key", "")
def _manual_scene(self, selected_name, scene_name, aliases, output_mode, mode, per_block, refinement_key, payload, sigmas=None):
target_name = normalize_scene_name(scene_name) or selected_name
scene = {
"name": target_name,
"aliases": aliases_from_text(aliases),
"output_mode": output_mode if output_mode in {"Manual", "Auto"} else "Manual",
"mode": mode if mode in {"ltx2", "wan"} else "ltx2",
"per_block": bool(per_block),
"refinement_key": normalize_refinement_key(refinement_key),
"positive_phrases": payload["positive_phrases"],
"negative_phrases": payload["negative_phrases"],
"loras": payload["loras"],
}
if isinstance(sigmas, torch.Tensor) and sigmas.numel() > 0:
scene["sigmas"] = tensor_to_serializable(sigmas.detach().cpu())
return scene
sigmas = torch.FloatTensor([])
if isinstance(template.get("sigmas"), dict):
def _scene_sigmas(self, scene, fallback=None):
if isinstance(scene.get("sigmas"), dict):
try:
sigmas = serializable_to_tensor(template["sigmas"]).detach().clone().cpu()
return serializable_to_tensor(scene["sigmas"]).detach().clone().cpu()
except Exception:
sigmas = torch.FloatTensor([])
pass
if isinstance(fallback, torch.Tensor):
return fallback.detach().clone().cpu()
return self._empty_sigmas()
positive_conditioning = None
def _scene_lora_stack(self, scene, positive_prompt):
mode = (scene.get("mode") or "ltx2").lower()
refinement_key = normalize_refinement_key(scene.get("refinement_key", ""))
loras = []
for index, row in enumerate(normalize_scene_loras(scene.get("loras", []))):
base_weight = float(row.get("strength", 1.0))
lora_type = scene_lora_type(row.get("type", "general"))
name = row["lora"]
loras.append({
"slot": index,
"name": name,
"type": lora_type,
"id": scene_lora_id(name, lora_type),
"base_model_weight": base_weight,
"model_weight": base_weight,
"source": "scene",
})
return {
"version": 2,
"refinement_key": refinement_key,
"mode": mode,
"per_block": bool(scene.get("per_block", False)),
"positive_prompt": positive_prompt,
"prompt_key": scene_prompt_key(positive_prompt, mode),
"loras": loras,
}
def _outputs_for_scene(self, name, scene, sigmas=None, source="Manual"):
positive = scene_text_from_phrases(scene.get("positive_phrases", []))
negative = scene_text_from_phrases(scene.get("negative_phrases", []))
refinement_key = normalize_refinement_key(scene.get("refinement_key", ""))
mode = scene.get("mode", "ltx2")
output_sigmas = self._scene_sigmas(scene, sigmas)
conditioning = None
conditioning_status = "No refinement key stored; conditioning not loaded."
if refinement_key:
positive_conditioning, conditioning_status = conditioning_from_refiner(
refinement_key,
mode,
positive_raw,
)
lora_stack = json_safe(template["lora_stack"]) if isinstance(template.get("lora_stack"), dict) else None
conditioning, conditioning_status = conditioning_from_refiner(refinement_key, mode, positive)
lora_stack = self._scene_lora_stack(scene, positive)
status = (
f"Template '{name}' loaded. Stored fields: {template_field_summary(template)}.\n"
f"Wildcard seed: {wildcard_seed or 'random'}.\n"
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"LoRAs: {len(lora_stack.get('loras', []))}.\n"
f"{conditioning_status}"
)
return (
positive_prompt,
negative_prompt,
activation_word,
return (positive, negative, name or scene.get("name", ""), output_sigmas, conditioning, lora_stack, refinement_key, status)
def _empty_outputs(self, status):
return ("", "", "", self._empty_sigmas(), None, None, "", status)
def build_scene(
self,
scene,
scene_name,
aliases,
action,
output_mode,
intent_prompt,
positive_prompt,
negative_prompt,
mode,
per_block,
refinement_key,
scene_payload,
refinement_key_input="",
sigmas=None,
):
linked_key = normalize_refinement_key(refinement_key_input)
if linked_key:
refinement_key = linked_key
selected_name = normalize_scene_name(scene)
output_mode = output_mode if output_mode in {"Manual", "Auto"} else "Manual"
action = action if action in {"load", "save", "update", "delete"} else "load"
payload = parse_scene_payload(scene_payload)
data = load_scene_db()
memory_changed = remember_scene_phrases(data, positive_prompt, negative_prompt)
scenes = data.setdefault("scenes", {})
manual = self._manual_scene(
selected_name,
scene_name,
aliases,
output_mode,
mode,
per_block,
refinement_key,
payload,
sigmas,
positive_conditioning,
lora_stack,
refinement_key if refinement_key else "",
status,
)
def manage_template(
self,
template,
name,
action,
mode,
wildcard_seed,
activation_word="",
refinement_key="",
positive_prompt="",
negative_prompt="",
sigmas=None,
lora_stack=None,
):
action = action if action in {"load", "save", "update", "delete"} else "load"
selected_name = normalize_template_name(template)
new_name = normalize_template_name(name)
data = load_template_db()
templates = data.setdefault("templates", {})
if action == "delete":
if not selected_name or selected_name not in templates:
return self._empty_outputs("Delete skipped: no selected template.")
deleted = templates.pop(selected_name)
save_template_db(data)
if not selected_name or selected_name not in scenes:
if memory_changed:
save_scene_db(data)
return self._empty_outputs("Delete skipped: no selected scene.")
deleted = scenes.pop(selected_name)
save_scene_db(data)
return self._empty_outputs(
f"Deleted template '{selected_name}'. Removed fields: {template_field_summary(deleted)}."
f"Deleted scene '{selected_name}'. Removed fields: {scene_field_summary(deleted)}."
)
if action == "save":
target_name = new_name if new_name and new_name not in templates else selected_name
target_name = normalize_scene_name(scene_name) or selected_name
if not target_name:
return self._empty_outputs("Save skipped: enter a unique name or select an existing template.")
saved = collect_template_payload(
mode,
activation_word,
refinement_key,
positive_prompt,
negative_prompt,
sigmas,
lora_stack,
update_only=False,
)
saved["name"] = target_name
saved["created_at"] = templates.get(target_name, {}).get("created_at", now_iso())
saved["updated_at"] = now_iso()
templates[target_name] = saved
save_template_db(data)
return self._loaded_outputs(target_name, saved, wildcard_seed)
if memory_changed:
save_scene_db(data)
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)
return self._outputs_for_scene(target_name, manual, sigmas, "Manual saved")
if action == "update":
if not selected_name or selected_name not in templates:
return self._empty_outputs("Update skipped: select an existing template.")
updated = collect_template_payload(
mode,
activation_word,
refinement_key,
positive_prompt,
negative_prompt,
sigmas,
lora_stack,
update_only=True,
existing=templates[selected_name],
)
updated["name"] = selected_name
updated["created_at"] = templates[selected_name].get("created_at", now_iso())
updated["updated_at"] = now_iso()
templates[selected_name] = updated
save_template_db(data)
return self._loaded_outputs(selected_name, updated, wildcard_seed)
target_name = selected_name or normalize_scene_name(scene_name)
if not target_name:
if memory_changed:
save_scene_db(data)
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)
return self._outputs_for_scene(target_name, manual, sigmas, "Manual updated")
if not selected_name or selected_name not in templates:
return self._empty_outputs("No template selected.")
return self._loaded_outputs(selected_name, templates[selected_name], wildcard_seed)
if output_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)
return self._outputs_for_scene(matched_name, matched_scene, sigmas, f"Auto {match_type}")
if memory_changed:
save_scene_db(data)
return self._outputs_for_scene(manual.get("name", ""), manual, sigmas, "Manual")
+166
View File
@@ -0,0 +1,166 @@
import json
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class FakeRoutes:
def get(self, _path):
return lambda func: func
def post(self, _path):
return lambda func: func
sys.modules.setdefault(
"folder_paths",
types.SimpleNamespace(get_user_directory=lambda: ""),
)
sys.modules.setdefault(
"server",
types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=FakeRoutes()))),
)
import templates
from templates import FunPackSceneBuilder, load_scene_db
def use_tmp_scene_store(monkeypatch, tmp_path):
monkeypatch.setattr(templates, "template_store_dir", lambda: str(tmp_path))
def test_scene_builder_collects_inputs_as_memory_but_outputs_selection(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
payload = {
"positive_phrases": ["red dress", "walking pose"],
"negative_phrases": ["bad hands"],
"loras": [{"on": True, "lora": "vampire.safetensors", "type": "character", "strength": 0.8}],
}
positive, negative, scene_name, _, _, lora_stack, refinement_key, status = builder.build_scene(
scene="-None-",
scene_name="Vampire Courtyard",
aliases="gothic vampire",
action="save",
output_mode="Manual",
intent_prompt="",
positive_prompt="woman in courtyard, red dress, moonlight",
negative_prompt="blurry, bad hands",
mode="ltx2",
per_block=True,
refinement_key="scene_key",
scene_payload=json.dumps(payload),
)
assert positive == "red dress, walking pose"
assert negative == "bad hands"
assert scene_name == "Vampire Courtyard"
assert refinement_key == "scene_key"
assert "moonlight" not in positive
assert lora_stack["per_block"] is True
assert lora_stack["loras"][0]["name"] == "vampire.safetensors"
assert lora_stack["loras"][0]["model_weight"] == 0.8
assert "Manual saved" in status
data = load_scene_db()
assert "woman in courtyard" in data["universal_memory"]
assert "blurry" in data["universal_memory"]
assert data["scenes"]["Vampire Courtyard"]["positive_phrases"] == ["red dress", "walking pose"]
def test_scene_builder_auto_exact_and_fuzzy_match_saved_scene(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
saved_payload = {
"positive_phrases": ["anthro dragon", "mossy scales"],
"negative_phrases": ["extra limbs"],
"loras": [],
}
manual_payload = {
"positive_phrases": ["manual fallback"],
"negative_phrases": [],
"loras": [],
}
builder.build_scene(
scene="-None-",
scene_name="Forest Dragon",
aliases="woodland beast",
action="save",
output_mode="Manual",
intent_prompt="",
positive_prompt="anthro dragon, mossy scales",
negative_prompt="extra limbs",
mode="ltx2",
per_block=False,
refinement_key="",
scene_payload=json.dumps(saved_payload),
)
exact = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
output_mode="Auto",
intent_prompt="please apply woodland beast now",
positive_prompt="ignored prompt",
negative_prompt="ignored negative",
mode="ltx2",
per_block=False,
refinement_key="",
scene_payload=json.dumps(manual_payload),
)
assert exact[0] == "anthro dragon, mossy scales"
assert exact[1] == "extra limbs"
assert exact[2] == "Forest Dragon"
assert "Auto exact" in exact[7]
fuzzy = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
output_mode="Auto",
intent_prompt="make it forest and dragon",
positive_prompt="ignored prompt",
negative_prompt="",
mode="ltx2",
per_block=False,
refinement_key="",
scene_payload=json.dumps(manual_payload),
)
assert fuzzy[0] == "anthro dragon, mossy scales"
assert "Auto fuzzy" in fuzzy[7]
def test_scene_builder_auto_no_match_falls_back_to_manual(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
payload = {
"positive_phrases": ["manual only"],
"negative_phrases": ["manual negative"],
"loras": [],
}
outputs = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
output_mode="Auto",
intent_prompt="no known scene here",
positive_prompt="source memory only",
negative_prompt="bad source memory",
mode="ltx2",
per_block=False,
refinement_key="",
scene_payload=json.dumps(payload),
)
assert outputs[0] == "manual only"
assert outputs[1] == "manual negative"
assert "Manual" in outputs[7]
+740
View File
@@ -0,0 +1,740 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "FunPackSceneBuilder";
const NONE_SCENE = "-None-";
const PAYLOAD_WIDGET = "scene_payload";
const ACTION_WIDGET = "action";
const LORA_TYPES = ["general", "action", "style", "quality", "character"];
const GROUP_ORDER = ["subject", "appearance", "action", "camera", "environment", "style", "quality", "details", "negative"];
let sceneData = null;
let loraValues = ["None"];
let activePicker = null;
const trackedNodes = new Set();
function widgetByName(node, name) {
return (node.widgets || []).find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget || widget.__funpackHidden) {
return;
}
widget.__funpackHidden = true;
widget.computeSize = () => [0, -4];
widget.type = "hidden";
}
function getWidgetValue(node, name, fallback = "") {
const widget = widgetByName(node, name);
return widget ? widget.value : fallback;
}
function setWidgetValue(node, name, value) {
const widget = widgetByName(node, name);
if (widget) {
widget.value = value;
}
}
function fitString(ctx, text, maxWidth) {
text = String(text ?? "");
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = "...";
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
low = mid;
} else {
high = mid - 1;
}
}
return text.slice(0, low) + ellipsis;
}
function roundRect(ctx, x, y, width, height, radius = 5) {
ctx.beginPath();
ctx.roundRect(x, y, width, height, [radius]);
}
function normalizePayload(payload) {
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (_error) {
payload = {};
}
}
if (!payload || typeof payload !== "object") {
payload = {};
}
return {
positive_phrases: Array.isArray(payload.positive_phrases) ? payload.positive_phrases.filter(Boolean) : [],
negative_phrases: Array.isArray(payload.negative_phrases) ? payload.negative_phrases.filter(Boolean) : [],
loras: Array.isArray(payload.loras) ? payload.loras.map(normalizeLoraRow) : [],
};
}
function payloadFromNode(node) {
return normalizePayload(getWidgetValue(node, PAYLOAD_WIDGET, "{}"));
}
function writePayload(node, payload) {
setWidgetValue(node, PAYLOAD_WIDGET, JSON.stringify(normalizePayload(payload)));
node.setDirtyCanvas(true, true);
}
function sceneNames() {
const names = sceneData?.scenes;
return Array.isArray(names) && names.length ? names : [NONE_SCENE];
}
function updateSceneCombo(node) {
const widget = widgetByName(node, "scene");
if (!widget) {
return;
}
const values = sceneNames();
const current = widget.value;
widget.options.values = current && !values.includes(current) ? [...values, current] : values;
if (!widget.value || !widget.options.values.includes(widget.value)) {
widget.value = widget.options.values[0] || NONE_SCENE;
}
}
async function fetchScenes() {
try {
const response = await api.fetchApi(`/funpack/scenes?cache_bust=${Date.now()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
sceneData = await response.json();
} catch (error) {
console.warn("FunPack: failed to refresh scenes", error);
}
return sceneData;
}
async function fetchLoras() {
try {
const response = await api.fetchApi(`/funpack/loras?cache_bust=${Date.now()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const values = await response.json();
if (Array.isArray(values) && values.length) {
loraValues = values;
}
} catch (error) {
console.warn("FunPack: failed to refresh LoRAs", error);
}
return loraValues;
}
async function refreshTracked() {
await Promise.all([fetchScenes(), fetchLoras()]);
for (const node of [...trackedNodes]) {
if (!node?.graph) {
trackedNodes.delete(node);
continue;
}
updateSceneCombo(node);
node.setDirtyCanvas(true, true);
}
}
function scheduleRefresh() {
window.setTimeout(() => void refreshTracked(), 100);
}
async function queueGraph() {
if (typeof app.queuePrompt === "function") {
return await app.queuePrompt(0);
}
const button = document.querySelector("#queue-button, .queue-button, button[title='Queue Prompt']");
if (button) {
button.click();
}
}
async function runSceneAction(node, action) {
setWidgetValue(node, ACTION_WIDGET, action);
node.setDirtyCanvas(true, true);
try {
await queueGraph();
} finally {
window.setTimeout(() => {
setWidgetValue(node, ACTION_WIDGET, "load");
scheduleRefresh();
node.setDirtyCanvas(true, true);
}, 250);
}
}
async function exportScenes() {
const response = await api.fetchApi(`/funpack/scenes/export?cache_bust=${Date.now()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`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_scenes.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function importScenes() {
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/scenes/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 || `Import failed with HTTP ${response.status}`);
}
await refreshTracked();
} catch (error) {
console.warn("FunPack: scene import failed", error);
app.canvas?.prompt?.("Scene import failed", error.message || String(error), () => {});
}
};
input.click();
}
function closePicker() {
if (activePicker) {
activePicker.remove();
activePicker = null;
}
}
function searchablePicker(event, values, currentValue, onSelect, title = "Search") {
closePicker();
const root = document.createElement("div");
Object.assign(root.style, {
position: "fixed",
zIndex: 10000,
minWidth: "320px",
maxWidth: "520px",
maxHeight: "420px",
padding: "8px",
border: "1px solid rgba(180, 190, 200, 0.35)",
borderRadius: "8px",
background: "rgba(30, 32, 36, 0.98)",
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.35)",
color: "#ddd",
font: "12px sans-serif",
});
const input = document.createElement("input");
input.type = "search";
input.placeholder = title;
input.value = currentValue && currentValue !== "None" ? currentValue : "";
Object.assign(input.style, {
boxSizing: "border-box",
width: "100%",
margin: "0 0 6px 0",
padding: "7px 8px",
border: "1px solid rgba(180, 190, 200, 0.35)",
borderRadius: "5px",
background: "#15171a",
color: "#fff",
outline: "none",
});
const list = document.createElement("div");
Object.assign(list.style, { maxHeight: "350px", overflowY: "auto" });
const render = () => {
const parts = input.value.toLowerCase().split(/\s+/).filter(Boolean);
const filtered = values.filter((value) => {
const haystack = String(value).toLowerCase().replace(/[_\-./\\]+/g, " ");
return parts.every((part) => haystack.includes(part));
}).slice(0, 160);
list.replaceChildren();
for (const value of filtered) {
const row = document.createElement("button");
row.type = "button";
row.textContent = value;
row.title = value;
Object.assign(row.style, {
display: "block",
width: "100%",
padding: "6px 8px",
border: "0",
borderRadius: "4px",
background: value === currentValue ? "rgba(120, 150, 170, 0.32)" : "transparent",
color: "#ddd",
textAlign: "left",
cursor: "pointer",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
row.addEventListener("click", () => {
onSelect(value);
closePicker();
});
list.append(row);
}
};
input.addEventListener("input", render);
input.addEventListener("keydown", (keyEvent) => {
if (keyEvent.key === "Escape") {
closePicker();
keyEvent.stopPropagation();
} else if (keyEvent.key === "Enter") {
const first = list.querySelector("button");
if (first) {
onSelect(first.textContent);
closePicker();
}
keyEvent.preventDefault();
keyEvent.stopPropagation();
}
});
root.append(input, list);
document.body.append(root);
activePicker = root;
const rect = root.getBoundingClientRect();
root.style.left = `${Math.min(window.innerWidth - rect.width - 12, Math.max(12, event.clientX ?? 12))}px`;
root.style.top = `${Math.min(window.innerHeight - rect.height - 12, Math.max(12, event.clientY ?? 12))}px`;
window.setTimeout(() => {
document.addEventListener("pointerdown", function outsideClick(clickEvent) {
if (!root.contains(clickEvent.target)) {
closePicker();
document.removeEventListener("pointerdown", outsideClick, true);
}
}, true);
}, 0);
render();
input.focus();
input.select();
}
function normalizeLoraRow(row = {}) {
const strength = Number(row.strength ?? row.base_model_weight ?? 1.0);
const type = row.type === "concept" ? "action" : row.type;
return {
on: row.on !== false,
lora: row.lora || row.name || "None",
type: LORA_TYPES.includes(type) ? type : "general",
strength: Number.isFinite(strength) ? strength : 1.0,
};
}
function scenePayloadFromStoredScene(scene) {
return normalizePayload({
positive_phrases: scene?.positive_phrases || [],
negative_phrases: scene?.negative_phrases || [],
loras: scene?.loras || [],
});
}
function storedScene(node) {
const name = getWidgetValue(node, "scene", NONE_SCENE);
return sceneData?.data?.scenes?.[name] || null;
}
function loadStoredSceneIntoNode(node) {
const name = getWidgetValue(node, "scene", NONE_SCENE);
const scene = storedScene(node);
if (!scene || name === NONE_SCENE) {
return;
}
if (node.__funpackLoadedScene === name) {
return;
}
node.__funpackLoadedScene = name;
setWidgetValue(node, "scene_name", scene.name || name);
setWidgetValue(node, "aliases", Array.isArray(scene.aliases) ? scene.aliases.join(", ") : "");
setWidgetValue(node, "output_mode", scene.output_mode || "Manual");
setWidgetValue(node, "mode", scene.mode || "ltx2");
setWidgetValue(node, "per_block", scene.per_block === true);
setWidgetValue(node, "refinement_key", scene.refinement_key || "");
writePayload(node, scenePayloadFromStoredScene(scene));
}
function button(ctx, widget, key, label, x, y, width, height) {
widget.hitAreas[key] = [x, y, width, height];
roundRect(ctx, x, y, width, height, 4);
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fill();
ctx.stroke();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(fitString(ctx, label, width - 8), x + width / 2, y + height / 2);
}
function drawChip(ctx, widget, key, text, x, y, selected, maxWidth) {
const width = Math.min(maxWidth, Math.max(34, ctx.measureText(text).width + 18));
widget.hitAreas[key] = [x, y, width, 19];
roundRect(ctx, x, y, width, 19, 5);
ctx.fillStyle = selected ? "rgba(72, 178, 112, 0.85)" : "rgba(255,255,255,0.065)";
ctx.strokeStyle = selected ? "rgba(150, 230, 170, 0.75)" : "rgba(255,255,255,0.13)";
ctx.fill();
ctx.stroke();
ctx.fillStyle = selected ? "#06140b" : LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(fitString(ctx, text, width - 10), x + width / 2, y + 9.5);
return width;
}
function selectedSet(list) {
return new Set((list || []).map((item) => String(item).toLowerCase()));
}
class SceneBuilderWidget {
constructor(node) {
this.node = node;
this.name = "funpack_scene_builder";
this.type = "custom";
this.options = { serialize: false };
this.value = "";
this.hitAreas = {};
}
computeSize(width) {
const payload = payloadFromNode(this.node);
const loraRows = Math.max(1, payload.loras.length);
return [width, 330 + loraRows * 22];
}
draw(ctx, node, width, y) {
loadStoredSceneIntoNode(node);
this.hitAreas = {};
const payload = payloadFromNode(node);
const memory = Array.isArray(sceneData?.memory) ? sceneData.memory : [];
const margin = 10;
let cy = y + 4;
const innerWidth = width - margin * 2;
ctx.save();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.font = "12px sans-serif";
ctx.fillText(`Scene Builder: ${getWidgetValue(node, "output_mode", "Manual")}`, margin, cy + 9);
button(ctx, this, "refresh", "Refresh", width - 244, cy, 56, 20);
button(ctx, this, "save", "Save", width - 184, cy, 44, 20);
button(ctx, this, "update", "Update", width - 136, cy, 56, 20);
button(ctx, this, "delete", "Delete", width - 76, cy, 56, 20);
cy += 27;
button(ctx, this, "import", "Import", width - 136, cy, 56, 20);
button(ctx, this, "export", "Export", width - 76, cy, 56, 20);
const intent = String(getWidgetValue(node, "intent_prompt", "") || "").trim();
ctx.globalAlpha = app.canvas.editor_alpha * 0.72;
ctx.fillText(intent ? `Intent: ${fitString(ctx, intent, innerWidth - 150)}` : "Intent: empty", margin, cy + 10);
ctx.globalAlpha = app.canvas.editor_alpha;
cy += 29;
const preview = payload.positive_phrases.join(", ");
ctx.fillStyle = "#58a6d6";
ctx.fillText(`Preview: ${fitString(ctx, preview || "no positive scene phrases selected", innerWidth)}`, margin, cy + 8);
cy += 25;
cy = this.drawSelectedSection(ctx, payload, "positive_phrases", "Selected Positive", margin, cy, innerWidth, node);
cy = this.drawSelectedSection(ctx, payload, "negative_phrases", "Selected Negative", margin, cy + 2, innerWidth, node);
cy = this.drawLoraSection(ctx, payload, margin, cy + 4, innerWidth, node);
this.drawMemoryBank(ctx, memory, payload, margin, cy + 8, innerWidth, node);
ctx.restore();
}
drawSelectedSection(ctx, payload, field, title, x, y, width, node) {
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.fillText(title, x, y + 8);
let cx = x;
let cy = y + 18;
const items = payload[field] || [];
if (!items.length) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
ctx.fillText("none", x, cy + 9);
ctx.globalAlpha = app.canvas.editor_alpha;
return cy + 24;
}
for (const item of items.slice(0, 18)) {
const key = `${field}:remove:${item}`;
const chipWidth = Math.min(width, Math.max(34, ctx.measureText(item).width + 18));
if (cx + chipWidth > x + width) {
cx = x;
cy += 23;
}
const used = drawChip(ctx, this, key, item, cx, cy, true, width);
cx += used + 5;
}
return cy + 25;
}
drawLoraSection(ctx, payload, x, y, width, node) {
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.fillText("Scene LoRAs", x, y + 8);
button(ctx, this, "lora:add", "+ Add LoRA", x + width - 90, y, 90, 20);
let cy = y + 23;
const rows = payload.loras.length ? payload.loras : [];
if (!rows.length) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
ctx.fillText("none", x, cy + 9);
ctx.globalAlpha = app.canvas.editor_alpha;
return cy + 24;
}
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
button(ctx, this, `lora:toggle:${index}`, row.on === false ? "OFF" : "ON", x, cy, 38, 19);
button(ctx, this, `lora:name:${index}`, row.lora || "None", x + 43, cy, Math.max(80, width - 190), 19);
button(ctx, this, `lora:type:${index}`, row.type || "general", x + width - 142, cy, 64, 19);
button(ctx, this, `lora:strength:${index}`, Number(row.strength ?? 1).toFixed(2), x + width - 73, cy, 48, 19);
button(ctx, this, `lora:delete:${index}`, "x", x + width - 20, cy, 20, 19);
cy += 22;
}
return cy;
}
drawMemoryBank(ctx, memory, payload, x, y, width, node) {
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "left";
ctx.fillText("Universal Phrase Bank", x, y + 8);
let cy = y + 23;
const positive = selectedSet(payload.positive_phrases);
const negative = selectedSet(payload.negative_phrases);
const byGroup = new Map();
for (const item of memory) {
const group = item.category || (item.source === "negative" ? "negative" : "details");
if (!byGroup.has(group)) {
byGroup.set(group, []);
}
byGroup.get(group).push(item);
}
for (const group of GROUP_ORDER) {
const items = (byGroup.get(group) || []).slice(0, 24);
if (!items.length) {
continue;
}
ctx.fillStyle = "#58a6d6";
ctx.fillText(group.toUpperCase(), x, cy + 8);
cy += 17;
let cx = x;
for (const item of items) {
const text = item.text || item.key;
const selected = group === "negative" ? negative.has(text.toLowerCase()) : positive.has(text.toLowerCase());
const key = `bank:${group}:${text}`;
const chipWidth = Math.min(width, Math.max(34, ctx.measureText(text).width + 18));
if (cx + chipWidth > x + width) {
cx = x;
cy += 23;
}
const used = drawChip(ctx, this, key, text, cx, cy, selected, width);
cx += used + 5;
}
cy += 25;
if (cy > y + 185) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText("Refresh after queueing to see more collected phrases.", x, cy + 8);
ctx.globalAlpha = app.canvas.editor_alpha;
break;
}
}
if (!memory.length) {
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText("Queue once with positive/negative prompts connected to collect phrases.", x, cy + 8);
ctx.globalAlpha = app.canvas.editor_alpha;
}
}
mouse(event, pos, node) {
if (event.type !== "pointerup") {
return event.type === "pointerdown" && this.hitAt(pos);
}
const hit = Object.entries(this.hitAreas).find(([, bounds]) => (
pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] &&
pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]
));
if (!hit) {
return false;
}
this.handleHit(hit[0], event, node);
return true;
}
hitAt(pos) {
return Object.values(this.hitAreas).some((bounds) => (
pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] &&
pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]
));
}
handleHit(key, event, node) {
const payload = payloadFromNode(node);
if (key === "refresh") {
void refreshTracked();
return;
}
if (["save", "update", "delete"].includes(key)) {
void runSceneAction(node, key);
return;
}
if (key === "import") {
importScenes();
return;
}
if (key === "export") {
void exportScenes();
return;
}
if (key.startsWith("positive_phrases:remove:")) {
const text = key.slice("positive_phrases:remove:".length);
payload.positive_phrases = payload.positive_phrases.filter((item) => item !== text);
writePayload(node, payload);
return;
}
if (key.startsWith("negative_phrases:remove:")) {
const text = key.slice("negative_phrases:remove:".length);
payload.negative_phrases = payload.negative_phrases.filter((item) => item !== text);
writePayload(node, payload);
return;
}
if (key.startsWith("bank:")) {
const [, group, ...rest] = key.split(":");
const text = rest.join(":");
const field = group === "negative" ? "negative_phrases" : "positive_phrases";
const lower = text.toLowerCase();
if (payload[field].some((item) => item.toLowerCase() === lower)) {
payload[field] = payload[field].filter((item) => item.toLowerCase() !== lower);
} else {
payload[field].push(text);
}
writePayload(node, payload);
return;
}
if (key === "lora:add") {
void fetchLoras().then((values) => {
searchablePicker(event, values, "None", (value) => {
payload.loras.push(normalizeLoraRow({ lora: value }));
writePayload(node, payload);
}, "Search LoRA");
});
return;
}
if (key.startsWith("lora:")) {
const [, action, rawIndex] = key.split(":");
const index = Number(rawIndex);
const row = payload.loras[index];
if (!row) {
return;
}
if (action === "toggle") {
row.on = row.on === false;
writePayload(node, payload);
} else if (action === "delete") {
payload.loras.splice(index, 1);
writePayload(node, payload);
} else if (action === "name") {
void fetchLoras().then((values) => {
searchablePicker(event, values, row.lora, (value) => {
row.lora = value;
writePayload(node, payload);
}, "Search LoRA");
});
} else if (action === "type") {
new LiteGraph.ContextMenu(LORA_TYPES, {
event,
title: "LoRA Type",
callback: (value) => {
row.type = value;
writePayload(node, payload);
},
});
} else if (action === "strength") {
app.canvas.prompt("LoRA strength", row.strength, (value) => {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
row.strength = parsed;
writePayload(node, payload);
}
}, event);
}
}
}
}
function removeSceneBuilderWidget(node) {
node.widgets = (node.widgets || []).filter((widget) => widget.name !== "funpack_scene_builder");
}
function setupSceneBuilderNode(node) {
trackedNodes.add(node);
hideWidget(widgetByName(node, ACTION_WIDGET));
hideWidget(widgetByName(node, PAYLOAD_WIDGET));
removeSceneBuilderWidget(node);
node.addCustomWidget(new SceneBuilderWidget(node));
updateSceneCombo(node);
void refreshTracked();
}
function wrapRefreshFunction(functionName) {
const original = app[functionName];
if (typeof original !== "function" || original.__funpackSceneBuilderWrapped) {
return;
}
const wrapped = function () {
const result = original.apply(this, arguments);
Promise.resolve(result).finally(() => scheduleRefresh());
return result;
};
wrapped.__funpackSceneBuilderWrapped = true;
app[functionName] = wrapped;
}
function hookComfyRefreshControls() {
for (const functionName of ["refreshComboInNodes", "refreshComboInNode", "refreshNodeDefs"]) {
wrapRefreshFunction(functionName);
}
}
app.registerExtension({
name: "funpack.sceneBuilder",
setup() {
hookComfyRefreshControls();
window.setTimeout(hookComfyRefreshControls, 0);
window.setTimeout(hookComfyRefreshControls, 1000);
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) {
return;
}
hookComfyRefreshControls();
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setupSceneBuilderNode(this);
};
const originalConfigure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
originalConfigure?.apply(this, arguments);
setupSceneBuilderNode(this);
};
},
});
-334
View File
@@ -1,334 +0,0 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "FunPackTemplateManager";
const NONE_TEMPLATE = "-None-";
const BUTTON_NAMES = new Set([
"funpack_template_save",
"funpack_template_update",
"funpack_template_delete",
"funpack_template_export",
"funpack_template_import",
"funpack_template_refresh",
]);
let cachedTemplates = [NONE_TEMPLATE];
let latestNodeData = null;
const trackedNodes = new Set();
let pendingRefresh = null;
function widgetByName(node, name) {
return (node.widgets || []).find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget || widget.__funpackHidden) {
return;
}
widget.__funpackHidden = true;
widget.computeSize = () => [0, -4];
widget.type = "hidden";
}
function templateWidget(node) {
return widgetByName(node, "template");
}
function actionWidget(node) {
return widgetByName(node, "action");
}
function fitString(ctx, text, maxWidth) {
text = String(text ?? "");
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = "...";
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
low = mid;
} else {
high = mid - 1;
}
}
return text.slice(0, low) + ellipsis;
}
function updateTemplateWidget(node, values = cachedTemplates) {
const widget = templateWidget(node);
if (!widget) {
return;
}
const current = widget.value;
const nextValues = current && !values.includes(current) ? [...values, current] : values;
if (widget.options) {
widget.options.values = nextValues;
}
if (!widget.value || !nextValues.includes(widget.value)) {
widget.value = nextValues[0] || NONE_TEMPLATE;
}
}
async function fetchTemplates() {
try {
const response = await api.fetchApi(`/funpack/templates?cache_bust=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const values = Array.isArray(data.templates) && data.templates.length ? data.templates : [NONE_TEMPLATE];
cachedTemplates = values;
return values;
} catch (error) {
console.warn("FunPack: failed to refresh template list", error);
return cachedTemplates;
}
}
async function refreshNode(node) {
const values = await fetchTemplates();
updateTemplateWidget(node, values);
node.setDirtyCanvas(true, true);
}
async function refreshTrackedNodes() {
if (pendingRefresh) {
return pendingRefresh;
}
pendingRefresh = (async () => {
const values = await fetchTemplates();
for (const node of [...trackedNodes]) {
if (!node?.graph) {
trackedNodes.delete(node);
continue;
}
updateTemplateWidget(node, values);
node.setDirtyCanvas(true, true);
}
})().finally(() => {
pendingRefresh = null;
});
return pendingRefresh;
}
function scheduleRefresh() {
window.setTimeout(() => {
void refreshTrackedNodes();
}, 100);
}
function wrapRefreshFunction(functionName) {
const original = app[functionName];
if (typeof original !== "function" || original.__funpackTemplateWrapped) {
return;
}
const wrapped = function () {
const result = original.apply(this, arguments);
Promise.resolve(result).finally(() => scheduleRefresh());
return result;
};
wrapped.__funpackTemplateWrapped = true;
app[functionName] = wrapped;
}
function hookComfyRefreshControls() {
for (const functionName of ["refreshComboInNodes", "refreshComboInNode", "refreshNodeDefs"]) {
wrapRefreshFunction(functionName);
}
}
function isRefreshControl(element) {
for (let current = element; current && current !== document.body; current = current.parentElement) {
const text = [
current.title,
current.ariaLabel,
current.textContent,
].filter(Boolean).join(" ").toLowerCase();
if (text.includes("refresh")) {
return true;
}
}
return false;
}
async function queueGraph() {
if (typeof app.queuePrompt === "function") {
return await app.queuePrompt(0);
}
const button = document.querySelector("#queue-button, .queue-button, button[title='Queue Prompt']");
if (button) {
button.click();
}
}
async function runTemplateAction(node, action) {
const widget = actionWidget(node);
if (!widget) {
return;
}
widget.value = action;
node.setDirtyCanvas(true, true);
try {
await queueGraph();
} finally {
window.setTimeout(() => {
widget.value = "load";
node.setDirtyCanvas(true, true);
scheduleRefresh();
}, 250);
}
}
async function exportTemplates() {
const response = await api.fetchApi(`/funpack/templates/export?cache_bust=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(`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_templates.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function importTemplates() {
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/templates/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 || `Import failed with HTTP ${response.status}`);
}
await refreshTrackedNodes();
} catch (error) {
console.warn("FunPack: template import failed", error);
app.canvas?.prompt?.("Template import failed", error.message || String(error), () => {});
}
};
input.click();
}
class FunPackTemplateButton {
constructor(name, label, callback) {
this.name = name;
this.type = "custom";
this.options = { serialize: false };
this.value = "";
this.label = label;
this.callback = callback;
}
computeSize(width) {
return [width, LiteGraph.NODE_WIDGET_HEIGHT + 2];
}
draw(ctx, _node, width, y, height) {
const x = 20;
const w = width - 40;
const h = height - 2;
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y + 1, w, h, [3]);
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fill();
ctx.stroke();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(fitString(ctx, this.label, w - 12), width / 2, y + height / 2);
ctx.restore();
}
mouse(event, _pos, node) {
if (event.type === "pointerup") {
this.callback(node);
return true;
}
return event.type === "pointerdown";
}
}
function removeTemplateButtons(node) {
node.widgets = (node.widgets || []).filter((widget) => !BUTTON_NAMES.has(widget.name));
}
function addTemplateButtons(node) {
removeTemplateButtons(node);
const buttons = [
new FunPackTemplateButton("funpack_template_save", "Save", (currentNode) => void runTemplateAction(currentNode, "save")),
new FunPackTemplateButton("funpack_template_update", "Update", (currentNode) => void runTemplateAction(currentNode, "update")),
new FunPackTemplateButton("funpack_template_delete", "Delete", (currentNode) => void runTemplateAction(currentNode, "delete")),
new FunPackTemplateButton("funpack_template_export", "Export", () => void exportTemplates()),
new FunPackTemplateButton("funpack_template_import", "Import", () => importTemplates()),
new FunPackTemplateButton("funpack_template_refresh", "Refresh", (currentNode) => void refreshNode(currentNode)),
];
for (const button of buttons) {
node.addCustomWidget(button);
}
}
function setupTemplateNode(node, nodeData) {
latestNodeData = nodeData;
trackedNodes.add(node);
hideWidget(actionWidget(node));
updateTemplateWidget(node);
addTemplateButtons(node);
void refreshNode(node);
}
app.registerExtension({
name: "funpack.templateManager",
setup() {
hookComfyRefreshControls();
window.setTimeout(hookComfyRefreshControls, 0);
window.setTimeout(hookComfyRefreshControls, 1000);
document.addEventListener("click", (event) => {
if (isRefreshControl(event.target)) {
scheduleRefresh();
}
}, true);
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) {
return;
}
latestNodeData = nodeData;
hookComfyRefreshControls();
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setupTemplateNode(this, nodeData);
};
const originalConfigure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
originalConfigure?.apply(this, arguments);
setupTemplateNode(this, nodeData, info);
};
},
});