Add FunPack Scene Builder
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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__ = [
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user