From a38fea93b75aa9611911afa84cd3ea3b2f21dce6 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 13 May 2026 22:10:25 +0300 Subject: [PATCH] Add FunPack Scene Builder --- CHANGELOG.md | 4 + README.md | 3 + __init__.py | 10 +- docs/FunPackSceneBuilder.md | 57 +++ docs/FunPackTemplateManager.md | 63 --- funpack.py | 10 +- templates.py | 674 ++++++++++++++++++++++++----- tests/test_scene_builder.py | 166 +++++++ web/funpack_scene_builder.js | 740 ++++++++++++++++++++++++++++++++ web/funpack_template_manager.js | 334 -------------- 10 files changed, 1543 insertions(+), 518 deletions(-) create mode 100644 docs/FunPackSceneBuilder.md delete mode 100644 docs/FunPackTemplateManager.md create mode 100644 tests/test_scene_builder.py create mode 100644 web/funpack_scene_builder.js delete mode 100644 web/funpack_template_manager.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e45fd4..b39d6f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d2177968..9470e8cb 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/__init__.py b/__init__.py index 9b7dc80a..4180b889 100644 --- a/__init__.py +++ b/__init__.py @@ -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__ = [ diff --git a/docs/FunPackSceneBuilder.md b/docs/FunPackSceneBuilder.md new file mode 100644 index 00000000..35925502 --- /dev/null +++ b/docs/FunPackSceneBuilder.md @@ -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. diff --git a/docs/FunPackTemplateManager.md b/docs/FunPackTemplateManager.md deleted file mode 100644 index bf0daf9b..00000000 --- a/docs/FunPackTemplateManager.md +++ /dev/null @@ -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. diff --git a/funpack.py b/funpack.py index 4013e4aa..defd1688 100644 --- a/funpack.py +++ b/funpack.py @@ -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", ] diff --git a/templates.py b/templates.py index 554b116b..02b8d7c8 100644 --- a/templates.py +++ b/templates.py @@ -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") diff --git a/tests/test_scene_builder.py b/tests/test_scene_builder.py new file mode 100644 index 00000000..b7e6588c --- /dev/null +++ b/tests/test_scene_builder.py @@ -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] diff --git a/web/funpack_scene_builder.js b/web/funpack_scene_builder.js new file mode 100644 index 00000000..492c738c --- /dev/null +++ b/web/funpack_scene_builder.js @@ -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); + }; + }, +}); diff --git a/web/funpack_template_manager.js b/web/funpack_template_manager.js deleted file mode 100644 index 0fd5c004..00000000 --- a/web/funpack_template_manager.js +++ /dev/null @@ -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); - }; - }, -});