From b0842cfdd76ea05a475ed391476e51bc19032b7f Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Mon, 4 May 2026 13:20:06 -0700 Subject: [PATCH] Add MXD utilities: LoRA, latents, LTX scaler Multiple MXD improvements and additions: - loraloader_mxd/power_lora_loader_mxd.py: import comfy modules; add robust bool/float coercion helpers; safer parsing of LORA_* inputs; apply LoRA without CLIP when needed; better error reporting. - maxedoutnodes.py: Add embed_workflow optional flag to SaveImage_MXD and filter extra PNG info when workflow embedding is disabled; add new Extract Workflow From Image MXD node to extract/save workflow JSON from image metadata; register/display name updates. - pyproject.toml: bump package version 1.9.0 -> 1.9.1. - wan22nodes.py: many improvements around latent file handling and prompt metadata: - sort latent files newest-first and list latent subfolders by latest mtime. - accept a hidden unique_id and attach source KSampler metadata to saved latents. - introduce robust graph parsing utilities to find KSampler nodes, collect upstream links and extract params reliably (with meta fallbacks). - use the new utilities across latent loading/saving nodes. - add LTX Image Scaler MXD node for LTX Video workflow (32-aligned resizing/cropping and tier selection). - remove wan_svi_first_last_mxd.py (deleted file). - web/js/betterCombos.js: add MXD alias constants and node configs for new/updated latent nodes. - web/js/image_comparer.js: change extension hooking to patch nodeType.prototype directly (avoids boundClass naming issue), add comparer_mode combo and ensure stored nodes keep correct title. Overall: improves robustness of LoRA application, latent file discovery/metadata, prompt graph parsing for reproducible sampler params, adds an LTX scaler node and workflow extraction helper, and updates frontend wiring for these MXD features. --- loraloader_mxd/power_lora_loader_mxd.py | 79 +++- maxedoutnodes.py | 147 +++++- pyproject.toml | 2 +- wan22nodes.py | 573 +++++++++++++++++++++--- wan_svi_first_last_mxd.py | 132 ------ web/js/betterCombos.js | 10 + web/js/image_comparer.js | 46 +- 7 files changed, 777 insertions(+), 212 deletions(-) delete mode 100644 wan_svi_first_last_mxd.py diff --git a/loraloader_mxd/power_lora_loader_mxd.py b/loraloader_mxd/power_lora_loader_mxd.py index 47ae5af..277fe2c 100644 --- a/loraloader_mxd/power_lora_loader_mxd.py +++ b/loraloader_mxd/power_lora_loader_mxd.py @@ -1,4 +1,6 @@ import folder_paths +import comfy.sd +import comfy.utils from typing import Union @@ -33,22 +35,77 @@ class MxdPowerLoraLoader: RETURN_NAMES = ("MODEL", "CLIP") FUNCTION = "load_loras" + @staticmethod + def _coerce_bool(value, default=False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + if value is None: + return default + return bool(value) + + @staticmethod + def _coerce_float(value, default=0.0) -> float: + if isinstance(value, bool): + return float(value) + try: + if value is None: + return float(default) + return float(value) + except (TypeError, ValueError): + return float(default) + + def _apply_lora_without_clip(self, model, lora, strength_model, strength_clip): + lora_path = folder_paths.get_full_path("loras", lora) + if not lora_path: + return model + loaded_lora = comfy.utils.load_torch_file(lora_path, safe_load=True) + model, _ = comfy.sd.load_lora_for_models(model, None, loaded_lora, strength_model, strength_clip) + return model + def load_loras(self, model=None, clip=None, **kwargs): for key, value in kwargs.items(): key = key.upper() - if key.startswith("LORA_") and "on" in value and "lora" in value and "strength" in value: - strength_model = value["strength"] - strength_clip = value["strengthTwo"] if "strengthTwo" in value else None + if not key.startswith("LORA_"): + continue + if not isinstance(value, dict): + log_node_warn(NODE_NAME, f'Skipping malformed LoRA input "{key}" (expected object).') + continue + if not all(k in value for k in ("on", "lora", "strength")): + log_node_warn(NODE_NAME, f'Skipping malformed LoRA input "{key}" (missing fields).') + continue + + strength_model = self._coerce_float(value.get("strength"), default=0.0) + strength_clip_raw = value.get("strengthTwo") + + if clip is None: + if strength_clip_raw is not None and self._coerce_float(strength_clip_raw, 0.0) != 0.0: + log_node_warn(NODE_NAME, "Received clip strength even though no clip supplied.") + strength_clip = 0.0 + else: + strength_clip = self._coerce_float(strength_clip_raw, default=strength_model) + + if not self._coerce_bool(value.get("on"), default=False): + continue + if strength_model == 0.0 and strength_clip == 0.0: + continue + + lora = get_lora_by_filename(value["lora"], log_node=self.NAME) + if model is None or lora is None: + continue + + try: if clip is None: - if strength_clip is not None and strength_clip != 0: - log_node_warn(NODE_NAME, "Received clip strength even though no clip supplied.") - strength_clip = 0 + model = self._apply_lora_without_clip(model, lora, strength_model, strength_clip) else: - strength_clip = strength_clip if strength_clip is not None else strength_model - if value["on"] and (strength_model != 0 or strength_clip != 0): - lora = get_lora_by_filename(value["lora"], log_node=self.NAME) - if model is not None and lora is not None: - model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip) + model, clip = LoraLoader().load_lora(model, clip, lora, strength_model, strength_clip) + except Exception as exc: + log_node_warn(NODE_NAME, f'Failed to apply LoRA "{value.get("lora")}" ({exc}). Skipping.') return (model, clip) diff --git a/maxedoutnodes.py b/maxedoutnodes.py index f2934d9..b8b612d 100644 --- a/maxedoutnodes.py +++ b/maxedoutnodes.py @@ -1296,16 +1296,38 @@ class SaveImage_MXD: "tooltip": "Choose whether to write files to disk, only preview, or save quietly." }), }, + "optional": { + "embed_workflow": ("BOOLEAN", { + "default": True, + "tooltip": "Embed workflow metadata when saving PNG previews/files." + }), + }, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } RETURN_TYPES = () OUTPUT_TOOLTIPS = ("Saves and/or previews the images.",) - def save(self, images, filename_prefix, mode, prompt=None, extra_pnginfo=None): + @staticmethod + def _filtered_extra_pnginfo(extra_pnginfo, embed_workflow): + if embed_workflow or not isinstance(extra_pnginfo, dict): + return extra_pnginfo + filtered = {k: v for k, v in extra_pnginfo.items() if str(k).lower() != "workflow"} + return filtered or None + + def save(self, images, filename_prefix, mode, embed_workflow=True, prompt=None, extra_pnginfo=None): + if embed_workflow: + save_prompt = prompt + save_extra_pnginfo = self._filtered_extra_pnginfo(extra_pnginfo, True) + else: + # Core SaveImage embeds the hidden `prompt` graph too. + # Drop both to truly disable workflow reconstruction from saved files. + save_prompt = None + save_extra_pnginfo = None + if mode.startswith("Preview"): - return PreviewImage().save_images(images, filename_prefix, prompt, extra_pnginfo) - result = SaveImage().save_images(images, filename_prefix, prompt, extra_pnginfo) + return PreviewImage().save_images(images, filename_prefix, save_prompt, save_extra_pnginfo) + result = SaveImage().save_images(images, filename_prefix, save_prompt, save_extra_pnginfo) if mode == "Save Only" and isinstance(result, dict): # Strip UI previews so nothing shows up in the ComfyUI viewer. return {k: v for k, v in result.items() if k != "ui"} @@ -1313,6 +1335,123 @@ class SaveImage_MXD: ######################################################################################################################## +class ExtractWorkflowFromImageMXD: + TITLE = "Extract Workflow From Image MXD" + CATEGORY = "MXD/Image" + OUTPUT_NODE = True + FUNCTION = "extract_and_save" + + DESCRIPTION = """Save workflow metadata to a JSON file from a wired image execution context.""" + + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + self.prefix_append = "" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE", {"tooltip": "Any connected image. Used to trigger extraction/save."}), + "filename_prefix": ("STRING", { + "default": "workflow/ComfyUI", + "tooltip": "Output JSON prefix. You can include subfolders, e.g. 'workflow/my_run'.", + }), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("json_path",) + OUTPUT_TOOLTIPS = ("Relative path to the saved JSON file in outputs.",) + + @staticmethod + def _decode_json_candidate(value): + if value is None: + return None + + if isinstance(value, (dict, list)): + return value + + if isinstance(value, bytes): + for enc in ("utf-8", "utf-16", "latin-1"): + try: + value = value.decode(enc) + break + except Exception: + continue + if isinstance(value, bytes): + value = value.decode("utf-8", "ignore") + + if not isinstance(value, str): + return None + + raw = value.strip() + if not raw: + return None + + if raw.lower().startswith("workflow:"): + raw = raw.split(":", 1)[1].strip() + + parsed = _safe_json_loads(raw) + if isinstance(parsed, (dict, list)): + return parsed + return None + + def _extract_workflow_from_context(self, prompt=None, extra_pnginfo=None): + if isinstance(extra_pnginfo, dict): + for key in ("workflow", "Workflow"): + parsed = self._decode_json_candidate(extra_pnginfo.get(key)) + if parsed is not None: + return parsed + + parsed_extra = self._decode_json_candidate(extra_pnginfo) + if isinstance(parsed_extra, dict): + for key in ("workflow", "Workflow"): + parsed = self._decode_json_candidate(parsed_extra.get(key)) + if parsed is not None: + return parsed + + if prompt is not None: + parsed_prompt = self._decode_json_candidate(prompt) + if parsed_prompt is not None: + return {"prompt": parsed_prompt} + if isinstance(prompt, dict): + return {"prompt": prompt} + + return None + + def extract_and_save(self, image, filename_prefix="workflow/ComfyUI", prompt=None, extra_pnginfo=None): + workflow = self._extract_workflow_from_context(prompt, extra_pnginfo) + if workflow is None: + raise ValueError( + "No workflow metadata is available in this execution context. " + "Connect generated images from the current run, or ensure workflow metadata is present." + ) + + filename_prefix += self.prefix_append + height = image[0].shape[0] + width = image[0].shape[1] + full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path( + filename_prefix, self.output_dir, width, height + ) + os.makedirs(full_output_folder, exist_ok=True) + + file = f"{filename}_{counter:05}_.json" + save_path = os.path.join(full_output_folder, file) + + with open(save_path, "w", encoding="utf-8", newline="\n") as f: + json.dump(workflow, f, ensure_ascii=False, indent=2) + + rel = os.path.join(subfolder, file) if subfolder else file + rel = rel.replace("\\", "/") + return { + "ui": {"text": [f"Saved workflow JSON: {rel}"]}, + "result": (rel,), + } + +######################################################################################################################## + class SmartCropByMaskMXD: @classmethod def INPUT_TYPES(cls): @@ -1394,6 +1533,7 @@ NODE_CLASS_MAPPINGS = { "LoadImageWithPromptsMXD": LoadImageWithPromptsMXD, "ZImageTurboEmptyLatentImage": ZImageTurboEmptyLatentImage, "Save Image MXD": SaveImage_MXD, + "Extract Workflow From Image MXD": ExtractWorkflowFromImageMXD, "SmartCropByMaskMXD": SmartCropByMaskMXD, } @@ -1421,6 +1561,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LoadImageWithPromptsMXD": "Load Image MXD", "ZImageTurboEmptyLatentImage": "ZIT Empty Latent Image MXD", "Save Image MXD": "Save Image MXD", + "Extract Workflow From Image MXD": "Extract Workflow From Image MXD", "SmartCropByMaskMXD": "Smart Crop by Mask MXD", } diff --git a/pyproject.toml b/pyproject.toml index ee56140..6cf5ab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI-MaxedOut" description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)" -version = "1.9.0" +version = "1.9.1" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/wan22nodes.py b/wan22nodes.py index 8316eb6..a9bef46 100644 --- a/wan22nodes.py +++ b/wan22nodes.py @@ -1,5 +1,6 @@ from __future__ import annotations import os, re, glob, json, hashlib +from collections import deque from typing import Any, Dict, Tuple, Optional, List, Union import torch @@ -38,6 +39,54 @@ VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".webm", ".avi"} routes = PromptServer.instance.routes +def _sort_paths_newest_first(paths: List[str]) -> List[str]: + """Sort file paths by mtime desc (newest first), stable by normalized path.""" + def _mtime(path: str) -> float: + try: + return os.path.getmtime(path) + except OSError: + return 0.0 + + return sorted( + paths, + key=lambda p: (-_mtime(p), p.replace("\\", "/").lower()), + ) + +def _list_latent_subfolders(latents_root: str) -> List[str]: + """ + List latent subfolders recursively (e.g. "a", "a/b"), newest first by + latest latent mtime in each branch. + """ + files = glob.glob(os.path.join(latents_root, "**", "*.latent"), recursive=True) + if not files: + return [] + + folder_latest_mtime: Dict[str, float] = {} + for file_path in files: + rel_dir = os.path.relpath(os.path.dirname(file_path), latents_root).replace(os.sep, "/").strip("/") + if not rel_dir or rel_dir == ".": + continue + try: + mtime = os.path.getmtime(file_path) + except OSError: + mtime = 0.0 + + # Include each ancestor so both "a" and "a/b" appear as options. + parts = [p for p in rel_dir.split("/") if p] + for i in range(1, len(parts) + 1): + branch = "/".join(parts[:i]) + prev = folder_latest_mtime.get(branch, -1.0) + if mtime > prev: + folder_latest_mtime[branch] = mtime + + return [ + folder + for folder, _ in sorted( + folder_latest_mtime.items(), + key=lambda kv: (-kv[1], kv[0].lower()), + ) + ] + @routes.get("/mxd/videos/input") async def mxd_list_input_videos(request): """ @@ -83,10 +132,10 @@ class SaveLatentMXD: "samples": ("LATENT", {"tooltip": "Latent tensor to save."}), "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "Prefix for saved latent filename."}), }, - "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}, } - def save_only(self, samples, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + def save_only(self, samples, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None, unique_id=None): # ---------- Save Latent ---------- latents_dir = os.path.join(folder_paths.get_input_directory(), "latents") @@ -107,6 +156,7 @@ class SaveLatentMXD: for k, v in extra_pnginfo.items(): try: meta[k] = json.dumps(v) except: pass + _attach_source_ksampler_metadata(meta, prompt, unique_id) file = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent") @@ -141,11 +191,11 @@ class SaveLatent_I2V_MXD: "negative": ("CONDITIONING", {"tooltip": "Negative CONDITIONING after WAN image→video."}), "filename_prefix": ("STRING", {"default": "I2V", "tooltip": "Prefix for saved files"}), }, - "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}, } def save_only(self, samples, positive, negative, filename_prefix="I2V", - prompt=None, extra_pnginfo=None): + prompt=None, extra_pnginfo=None, unique_id=None): # ---- save latent (.latent) ---- latents_dir = os.path.join(folder_paths.get_input_directory(), "latents") @@ -166,6 +216,7 @@ class SaveLatent_I2V_MXD: for k, v in extra_pnginfo.items(): try: meta[k] = json.dumps(v) except: pass + _attach_source_ksampler_metadata(meta, prompt, unique_id) latent_path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent") @@ -227,10 +278,210 @@ def _safe_json_loads(s: Union[str, bytes, None]) -> Optional[Dict[str, Any]]: return None -def _extract_params_from_prompt_json(prompt_json: Dict[str, Any]) -> Tuple[str, str, int, float, str, str, int]: +def _node_sort_key(node_id: str) -> Tuple[int, Union[int, str]]: + s = str(node_id) + try: + return (0, int(s)) + except Exception: + return (1, s) + + +def _normalize_prompt_graph(prompt_json: Any) -> Dict[str, Any]: + if not isinstance(prompt_json, dict): + return {} + graph = prompt_json.get("prompt", prompt_json) + return graph if isinstance(graph, dict) else {} + + +def _get_graph_node(graph: Dict[str, Any], node_id: Any) -> Optional[Dict[str, Any]]: + if node_id is None or not isinstance(graph, dict): + return None + node = graph.get(str(node_id)) + return node if isinstance(node, dict) else None + + +def _iter_graph_nodes_sorted(graph: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + nodes: List[Tuple[str, Dict[str, Any]]] = [] + for node_id, node in graph.items(): + if isinstance(node, dict): + nodes.append((str(node_id), node)) + nodes.sort(key=lambda pair: _node_sort_key(pair[0])) + return nodes + + +def _linked_node_id(value: Any) -> Optional[str]: + if isinstance(value, (list, tuple)) and len(value) >= 1: + return str(value[0]) + return None + + +def _is_ksampler_node(node: Any) -> bool: + if not isinstance(node, dict): + return False + return "KSampler" in str(node.get("class_type", "")) + + +def _collect_upstream_linked_node_ids(node: Dict[str, Any]) -> List[str]: + inputs = node.get("inputs", {}) + if not isinstance(inputs, dict): + return [] + + seen = set() + ordered = [] + + # Prefer latent-carrying links first. + for key in ("samples", "latent", "latent_image"): + linked = _linked_node_id(inputs.get(key)) + if linked is not None and linked not in seen: + seen.add(linked) + ordered.append(linked) + + # Then search all other connected inputs in stable order. + for key, value in inputs.items(): + if key in ("samples", "latent", "latent_image"): + continue + linked = _linked_node_id(value) + if linked is not None and linked not in seen: + seen.add(linked) + ordered.append(linked) + + return ordered + + +def _find_upstream_ksampler_node_id(graph: Dict[str, Any], start_node_id: Any) -> Optional[str]: + if not isinstance(graph, dict) or start_node_id is None: + return None + + queue: deque[str] = deque([str(start_node_id)]) + visited = set() + + while queue: + node_id = queue.popleft() + if node_id in visited: + continue + visited.add(node_id) + + node = _get_graph_node(graph, node_id) + if not node: + continue + if _is_ksampler_node(node): + return node_id + + for upstream_id in _collect_upstream_linked_node_ids(node): + if upstream_id not in visited: + queue.append(upstream_id) + + return None + + +def _extract_ksampler_params(node: Dict[str, Any]) -> Dict[str, Any]: + inputs = node.get("inputs", {}) if isinstance(node, dict) else {} + if not isinstance(inputs, dict): + inputs = {} + + out: Dict[str, Any] = {} + + def set_int(key: str): + if key in inputs: + try: + out[key] = int(inputs[key]) + except Exception: + pass + + def set_float(key: str): + if key in inputs: + try: + out[key] = float(inputs[key]) + except Exception: + pass + + def set_str(key: str): + if key in inputs and not isinstance(inputs[key], (list, tuple, dict)): + try: + out[key] = str(inputs[key]).strip() + except Exception: + pass + + set_int("steps") + set_float("cfg") + set_str("sampler_name") + set_str("scheduler") + set_int("start_at_step") + set_int("end_at_step") + + return out + + +def _attach_source_ksampler_metadata(meta: Dict[str, Any], prompt: Any, unique_id: Any) -> None: + if not isinstance(meta, dict): + return + + graph = _normalize_prompt_graph(prompt) + if not graph: + return + + save_node_id = str(unique_id) if unique_id is not None else "" + if not save_node_id: + return + + save_node = _get_graph_node(graph, save_node_id) + if not save_node: + return + + source_candidates = _collect_upstream_linked_node_ids(save_node) + if not source_candidates: + return + + source_ksampler_id = None + for start_id in source_candidates: + source_ksampler_id = _find_upstream_ksampler_node_id(graph, start_id) + if source_ksampler_id: + break + + if not source_ksampler_id: + return + + source_node = _get_graph_node(graph, source_ksampler_id) + if not source_node: + return + + meta["mxd_source_save_node_id"] = save_node_id + meta["mxd_source_ksampler_node_id"] = source_ksampler_id + try: + meta["mxd_source_ksampler_params"] = json.dumps(_extract_ksampler_params(source_node)) + except Exception: + pass + + +def _extract_prompt_text_from_ksampler(graph: Dict[str, Any], ks_node: Dict[str, Any]) -> Tuple[str, str]: + pos = "" + neg = "" + + inputs = ks_node.get("inputs", {}) if isinstance(ks_node, dict) else {} + if not isinstance(inputs, dict): + return pos, neg + + def _text_from_clip(link_value: Any) -> str: + node_id = _linked_node_id(link_value) + if node_id is None: + return "" + node = _get_graph_node(graph, node_id) or {} + if node.get("class_type") == "CLIPTextEncode": + return str(node.get("inputs", {}).get("text", "")).strip() + return "" + + pos = _text_from_clip(inputs.get("positive")) + neg = _text_from_clip(inputs.get("negative")) + return pos, neg + + +def _extract_params_from_prompt_json( + prompt_json: Dict[str, Any], + meta: Optional[Dict[str, Any]] = None, +) -> Tuple[str, str, int, float, str, str, int]: """ Returns: (positive, negative, steps, cfg, sampler_name, scheduler, end_at_step) - parsed from the saved Comfy prompt graph (KSamplerAdvanced only). + parsed from the saved Comfy prompt graph with deterministic KSampler selection. """ pos = "" neg = "" @@ -240,55 +491,87 @@ def _extract_params_from_prompt_json(prompt_json: Dict[str, Any]) -> Tuple[str, scheduler = "" end_at_step = 0 - # unwrap if saved as {"prompt": {...}} - graph = prompt_json.get("prompt", prompt_json) if isinstance(prompt_json, dict) else {} + graph = _normalize_prompt_graph(prompt_json) if not isinstance(graph, dict): return pos, neg, steps, cfg, sampler_name, scheduler, end_at_step - # find the KSampler/KSamplerAdvanced node - ks = None - for _, v in graph.items(): - if "KSampler" in v.get("class_type", ""): # matches KSamplerAdvanced too - ks = v - break - if not ks: + ks_node = None + extracted_params: Dict[str, Any] = {} + + # 1) Source KSampler id saved directly in latent metadata. + if isinstance(meta, dict): + raw_ks = meta.get("mxd_source_ksampler_node_id") + if raw_ks is not None: + candidate = _get_graph_node(graph, str(raw_ks)) + if candidate and _is_ksampler_node(candidate): + ks_node = candidate + + # 2) Source save node id -> trace upstream to nearest KSampler. + if ks_node is None and isinstance(meta, dict): + raw_save = meta.get("mxd_source_save_node_id") + if raw_save is not None: + save_node = _get_graph_node(graph, str(raw_save)) + if save_node: + for start_id in _collect_upstream_linked_node_ids(save_node): + trace_id = _find_upstream_ksampler_node_id(graph, start_id) + if trace_id: + candidate = _get_graph_node(graph, trace_id) + if candidate and _is_ksampler_node(candidate): + ks_node = candidate + break + + # 3) Legacy fallback: last KSampler node in graph. + if ks_node is None: + for _, node in _iter_graph_nodes_sorted(graph): + if _is_ksampler_node(node): + ks_node = node + + if not ks_node: return pos, neg, steps, cfg, sampler_name, scheduler, end_at_step - kin = ks.get("inputs", {}) + pos, neg = _extract_prompt_text_from_ksampler(graph, ks_node) + extracted_params = _extract_ksampler_params(ks_node) - # follow links to CLIPTextEncode nodes for prompts - def _as_node_id(x): - return str(x[0]) if isinstance(x, (list, tuple)) and x else None + if "steps" in extracted_params: + steps = int(extracted_params["steps"]) + if "cfg" in extracted_params: + cfg = float(extracted_params["cfg"]) + if "end_at_step" in extracted_params: + end_at_step = int(extracted_params["end_at_step"]) + if "sampler_name" in extracted_params: + sampler_name = str(extracted_params["sampler_name"]).strip() + if "scheduler" in extracted_params: + scheduler = str(extracted_params["scheduler"]).strip() - def _text_from_clip(node_id): - n = graph.get(str(node_id), {}) - if n.get("class_type") == "CLIPTextEncode": - return str(n.get("inputs", {}).get("text", "")).strip() - return "" - - pos = _text_from_clip(_as_node_id(kin.get("positive"))) - neg = _text_from_clip(_as_node_id(kin.get("negative"))) - - # numeric params - if "steps" in kin: - try: - steps = int(kin["steps"]) - except Exception: - pass - if "cfg" in kin: - try: - cfg = float(kin["cfg"]) - except Exception: - pass - if "end_at_step" in kin: - try: - end_at_step = int(kin["end_at_step"]) - except Exception: - pass - - # strings (combos) - sampler_name = str(kin.get("sampler_name", "")).strip() - scheduler = str(kin.get("scheduler", "")).strip() + # Fallback to saved parameter snapshot if graph parse is incomplete. + if isinstance(meta, dict): + saved_params = _safe_json_loads(meta.get("mxd_source_ksampler_params")) + if isinstance(saved_params, dict): + if "steps" in saved_params and "steps" not in extracted_params: + try: + steps = int(saved_params["steps"]) + except Exception: + pass + if "cfg" in saved_params and "cfg" not in extracted_params: + try: + cfg = float(saved_params["cfg"]) + except Exception: + pass + if "end_at_step" in saved_params and "end_at_step" not in extracted_params: + try: + end_at_step = int(saved_params["end_at_step"]) + except Exception: + pass + if "sampler_name" in saved_params and "sampler_name" not in extracted_params: + try: + sampler_name = str(saved_params["sampler_name"]).strip() + except Exception: + pass + if "scheduler" in saved_params and "scheduler" not in extracted_params: + try: + scheduler = str(saved_params["scheduler"]).strip() + except Exception: + pass return pos, neg, steps, cfg, sampler_name, scheduler, end_at_step @@ -307,7 +590,7 @@ class LoadLatent_WithParams: os.makedirs(latents_root, exist_ok=True) files = glob.glob(os.path.join(latents_root, "**", "*.latent"), recursive=True) - files.sort() + files = _sort_paths_newest_first(files) options = [os.path.relpath(f, latents_root).replace(os.sep, "/") for f in files] # live enums from KSamplerAdvanced so values wire cleanly @@ -480,7 +763,7 @@ class LoadLatent_WithParams: samples = {"samples": t.unsqueeze(0)} prompt_json = _safe_json_loads(meta.get("prompt")) - pos, neg, steps, cfg, sampler_name, scheduler, end_at_step = _extract_params_from_prompt_json(prompt_json or {}) + pos, neg, steps, cfg, sampler_name, scheduler, end_at_step = _extract_params_from_prompt_json(prompt_json or {}, meta) # SD3 shift (not in KSamplerAdvanced, but we want it) shift = self._extract_sd3_shift(meta, prompt_json) @@ -568,10 +851,7 @@ class LoadLatents_FromFolder_WithParams: def INPUT_TYPES(s): latents_root = os.path.join(folder_paths.get_input_directory(), "latents") os.makedirs(latents_root, exist_ok=True) - subs = [""] + sorted([ - d for d in os.listdir(latents_root) - if os.path.isdir(os.path.join(latents_root, d)) - ]) + subs = [""] + _list_latent_subfolders(latents_root) # 🔧 FIX: safely import enums inside function to avoid overwriting RETURN_TYPES from nodes import KSamplerAdvanced @@ -686,7 +966,7 @@ class LoadLatents_FromFolder_WithParams: latents_root = os.path.join(folder_paths.get_input_directory(), "latents") base = os.path.join(latents_root, subfolder) if subfolder else latents_root files = glob.glob(os.path.join(base, "**", "*.latent"), recursive=True) - files.sort() + files = _sort_paths_newest_first(files) if not files: raise RuntimeError(f"[LoadLatents_FromFolder_WithParams] No .latent files found in '{base}'.") @@ -705,7 +985,7 @@ class LoadLatents_FromFolder_WithParams: slices = [t.unsqueeze(0)] prompt_json = _safe_json_loads(meta.get("prompt")) - pos, neg, n_steps, cfg, sampler_name, scheduler, end_at_step = _extract_params_from_prompt_json(prompt_json or {}) + pos, neg, n_steps, cfg, sampler_name, scheduler, end_at_step = _extract_params_from_prompt_json(prompt_json or {}, meta) sampler_name = self._coerce_enum(sampler_name, getattr(self.__class__, "_SAMPLERS_ENUM", ())) scheduler = self._coerce_enum(scheduler, getattr(self.__class__, "_SCHEDULERS_ENUM", ())) shift_val = self._extract_sd3_shift(meta, prompt_json) @@ -782,7 +1062,7 @@ class LoadLatent_I2V_MXD(LoadLatent_WithParams): latents_root = os.path.join(folder_paths.get_input_directory(), "latents") os.makedirs(latents_root, exist_ok=True) files = glob.glob(os.path.join(latents_root, "**", "*.latent"), recursive=True) - files.sort() + files = _sort_paths_newest_first(files) # Clean dropdown display (no "latents/" prefix) options = [os.path.relpath(f, latents_root).replace(os.sep, "/") for f in files] @@ -890,10 +1170,7 @@ class LoadLatents_FromFolder_I2V_MXD(LoadLatents_FromFolder_WithParams): # Same folder logic as the base class latents_root = os.path.join(folder_paths.get_input_directory(), "latents") os.makedirs(latents_root, exist_ok=True) - subs = [""] + sorted([ - d for d in os.listdir(latents_root) - if os.path.isdir(os.path.join(latents_root, d)) - ]) + subs = [""] + _list_latent_subfolders(latents_root) # Pull live enums from KSamplerAdvanced so sampler/scheduler wire cleanly from nodes import KSamplerAdvanced @@ -923,7 +1200,7 @@ class LoadLatents_FromFolder_I2V_MXD(LoadLatents_FromFolder_WithParams): latents_root = os.path.join(folder_paths.get_input_directory(), "latents") base = os.path.join(latents_root, subfolder) if subfolder else latents_root files = glob.glob(os.path.join(base, "**", "*.latent"), recursive=True) - files.sort() + files = _sort_paths_newest_first(files) if not files: raise RuntimeError(f"[LoadLatents_FromFolder_I2V_MXD] No .latent files found in '{base}'.") @@ -944,7 +1221,7 @@ class LoadLatents_FromFolder_I2V_MXD(LoadLatents_FromFolder_WithParams): prompt_json = _safe_json_loads(meta.get("prompt")) pos, neg, n_steps, cfg, sampler_name, scheduler, end_at_step = \ - _extract_params_from_prompt_json(prompt_json or {}) + _extract_params_from_prompt_json(prompt_json or {}, meta) sampler_name = self._coerce_enum(sampler_name, getattr(self.__class__, "_SAMPLERS_ENUM", ())) scheduler = self._coerce_enum(scheduler, getattr(self.__class__, "_SCHEDULERS_ENUM", ())) @@ -2034,6 +2311,7 @@ if HAVE_COMFY_API: "remote": { "route": "/mxd/videos/input", "refresh_button": True, + "control_after_refresh": "first", }, }), } @@ -2326,6 +2604,173 @@ if HAVE_COMFY_API: return io.NodeOutput(positive, negative, out_latent) +# ============================================================ +# LTX Video Image Scaler MXD +# ============================================================ +# LTX Video requires all dimensions to be multiples of 32. +# Tiers: 480p / 768 / 1024 (or Auto to pick nearest by area) +# Fit (no pad): proportional resize <= target, /32 aligned. +# Crop (no pad): resize-to-cover then center-crop to exact bucket. +# Square images map to each tier's square bucket. +# Buckets (all /32): +# 480p: 832x480 / 480x832 / 512x512 +# 768: 1280x768 / 768x1280 / 768x768 +# 1024: 1792x1024 / 1024x1792 / 1024x1024 +# ============================================================ + +_LTX_BUCKETS = { + "480p": {"landscape": (832, 480), "portrait": (480, 832), "square": (512, 512)}, + "768": {"landscape": (1280, 768), "portrait": (768, 1280), "square": (768, 768)}, + "1024": {"landscape": (1792, 1024), "portrait": (1024, 1792), "square": (1024, 1024)}, +} + +_LTX_TIER_AREAS = { + "480p": 832 * 480, # 399,360 + "768": 1280 * 768, # 983,040 + "1024": 1792 * 1024, # 1,835,008 +} + +_LTX_VALID_RES = {b for t in _LTX_BUCKETS.values() for b in t.values()} + + +def _ceil32(x): + x = (int(x) + 31) // 32 * 32 + return max(32, x) + + +def _floor32(x): + x = int(x) // 32 * 32 + return max(32, x) + + +def _ltx_is_valid_res(w, h): + return (w, h) in _LTX_VALID_RES + + +def _ltx_resize_fit_inside(img, out_w, out_h): + """Resize to fit inside (out_w, out_h), output /32 aligned on both sides.""" + _, ih, iw, _ = img.shape + s = min(out_w / iw, out_h / ih) + tw = _floor32(iw * s) + th = _floor32(ih * s) + tw = max(32, min(tw, nodes.MAX_RESOLUTION)) + th = max(32, min(th, nodes.MAX_RESOLUTION)) + resized = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1) + return resized, tw, th + + +def _ltx_resize_then_center_crop(img, out_w, out_h): + """Resize to cover (out_w, out_h) then center-crop to exact /32 target.""" + _, ih, iw, _ = img.shape + s = max(out_w / iw, out_h / ih) + tw = _ceil32(iw * s) + th = _ceil32(ih * s) + tmp = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1) + y0 = max(0, (th - out_h) // 2) + x0 = max(0, (tw - out_w) // 2) + return tmp[:, y0:y0+out_h, x0:x0+out_w, :] + + +def _ltx_pick_tier_auto(iw, ih): + """Pick the LTX tier whose reference area is closest to the input area.""" + area = iw * ih + return min(_LTX_TIER_AREAS, key=lambda t: abs(area - _LTX_TIER_AREAS[t])) + + +def _ltx_pick_bucket(iw, ih, tier): + """Pick the landscape / portrait / square bucket for the given tier.""" + tier_map = _LTX_BUCKETS[tier] + if _is_squareish(iw, ih): + return tier_map["square"] + return tier_map["landscape"] if iw >= ih else tier_map["portrait"] + + +def _ltx_scale_image_core(image, tier="Auto", crop_to_fit=True): + """ + Core LTX scaler. Returns (scaled_image, out_w, out_h, passthrough). + passthrough=True only when Safe Auto detects an already-valid resolution. + """ + _, ih, iw, _ = image.shape + + if tier == "Safe Auto": + if _ltx_is_valid_res(iw, ih): + return image, iw, ih, True + area = iw * ih + min_area = int(_LTX_TIER_AREAS["480p"] * 0.5) + max_area = int(_LTX_TIER_AREAS["1024"] * 1.8) + if area < min_area or area > max_area: + size_label = "small" if area < min_area else "large" + raise ValueError( + f"[LTX_Image_Scaler_MXD] Input {iw}x{ih} is too {size_label} for LTX Video buckets.\n" + "LTX Video works best around:\n" + " - 480p tier: 832x480 / 480x832 / 512x512\n" + " - 768 tier: 1280x768 / 768x1280 / 768x768\n" + " - 1024 tier: 1792x1024 / 1024x1792 / 1024x1024\n\n" + "Use a source image closer to one of these tiers, or process it " + "through your LTX workflow first." + ) + tier = "Auto" + + if tier == "Auto": + tier = _ltx_pick_tier_auto(iw, ih) + + bw, bh = _ltx_pick_bucket(iw, ih, tier) + + if _is_squareish(iw, ih): + crop_to_fit = False + + if crop_to_fit: + out = _ltx_resize_then_center_crop(image, bw, bh) + else: + out, bw, bh = _ltx_resize_fit_inside(image, bw, bh) + + return out, int(out.shape[2]), int(out.shape[1]), False + + +class LTX_Image_Scaler_MXD: + """ + MXD Image Scaler for LTX Video — all outputs are multiples of 32. + + Tiers: + Auto — picks the tier whose area is closest to the input. + 480p — targets 832x480 / 480x832 / 512x512. + 768 — targets 1280x768 / 768x1280 / 768x768. + 1024 — targets 1792x1024 / 1024x1792 / 1024x1024. + + Modes: + Perfect Fit (Crops Edges) resize-to-cover + center-crop to exact bucket size. + Closest Fit (No Crop) proportional resize, /32-aligned; may be smaller than bucket. + + Square images (aspect ratio within +-3% of 1:1) map to the tier's square bucket. + Returns image + width + height so downstream nodes can read the final dims directly. + """ + + TITLE = "LTX Video Image Scaler MXD" + CATEGORY = "image/processing" + RETURN_TYPES = ("IMAGE", "INT", "INT") + RETURN_NAMES = ("image", "width", "height") + FUNCTION = "scale" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "tier": (["Auto", "480p", "768", "1024"], {"default": "Auto"}), + "crop_to_fit": ("BOOLEAN", { + "default": True, + "label_on": "Perfect Fit (Crops Edges)", + "label_off": "Closest Fit (No Crop)", + }), + } + } + + def scale(self, image, tier="Auto", crop_to_fit=True): + image = _validate_image_batch_4d(image, "LTX_Image_Scaler_MXD", "image") + out, ow, oh, _ = _ltx_scale_image_core(image, tier=tier, crop_to_fit=crop_to_fit) + return (out, ow, oh) + + # ---------- Node registration ---------- NODE_CLASS_MAPPINGS = { "SaveLatentMXD": SaveLatentMXD, @@ -2337,6 +2782,7 @@ NODE_CLASS_MAPPINGS = { "LoadLatent_I2V_MXD": LoadLatent_I2V_MXD, "LoadLatents_FromFolder_I2V_MXD": LoadLatents_FromFolder_I2V_MXD, "WAN22_I2V_Image_Scaler_MXD": WAN22_I2V_Image_Scaler_MXD, + "LTX_Image_Scaler_MXD": LTX_Image_Scaler_MXD, "WAN22_I2V_Match_Resolution_MXD": WAN22_I2V_Match_Resolution_MXD, "Frames_Remove_From_Start_MXD": Frames_Remove_From_Start_MXD, "GroupVideoFramesMXD": GroupVideoFramesMXD, @@ -2365,6 +2811,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LoadLatent_I2V_MXD": "Load Latent I2V MXD", "LoadLatents_FromFolder_I2V_MXD": "Load Latent Batch I2V MXD", "WAN22_I2V_Image_Scaler_MXD": "Image Scaler Wan 2.2 I2V MXD", + "LTX_Image_Scaler_MXD": "LTX Video Image Scaler MXD", "WAN22_I2V_Match_Resolution_MXD": "Match Resolution Wan 2.2 I2V MXD", "Frames_Remove_From_Start_MXD": "Remove Frames From Start MXD", "GroupVideoFramesMXD": "Group Video Frames MXD", diff --git a/wan_svi_first_last_mxd.py b/wan_svi_first_last_mxd.py deleted file mode 100644 index 57c07a2..0000000 --- a/wan_svi_first_last_mxd.py +++ /dev/null @@ -1,132 +0,0 @@ -from comfy_api.latest import io -import torch -import node_helpers -import comfy -import comfy.latent_formats - - -class Wan22FirstLastImageToVideoSVIMXD(io.ComfyNode): - @classmethod - def define_schema(cls) -> io.Schema: - return io.Schema( - node_id="Wan22FirstLastImageToVideoSVIMXD", - display_name="WAN 2.2 First/Last I2V SVI MXD", - category="ComfyUI-Wan22FMLF", - inputs=[ - io.Conditioning.Input("positive"), - io.Conditioning.Input("negative"), - io.Vae.Input("vae"), - io.Int.Input("length", default=81, min=1, max=8192, step=4), - io.Int.Input("batch_size", default=1, min=1, max=4096), - io.Image.Input("start_image", optional=True), - io.Image.Input("end_image", optional=True), - io.Latent.Input("prev_latent", optional=True), - io.Int.Input("continue_frames_count", default=5, min=0, max=20, step=1, optional=True), - ], - outputs=[ - io.Conditioning.Output(display_name="positive"), - io.Conditioning.Output(display_name="negative"), - io.Latent.Output(display_name="latent"), - ], - ) - - @classmethod - def execute( - cls, - positive, - negative, - vae, - length, - batch_size, - start_image=None, - end_image=None, - prev_latent=None, - continue_frames_count=5, - ) -> io.NodeOutput: - _ = end_image # SVI-only node keeps this input for wiring compatibility and intentionally ignores it. - - spacial_scale = vae.spacial_compression_encode() - latent_channels = vae.latent_channels - total_latents = ((length - 1) // 4) + 1 - device = comfy.model_management.intermediate_device() - - prev_samples = None - if prev_latent is not None: - if not isinstance(prev_latent, dict) or prev_latent.get("samples") is None: - raise ValueError("prev_latent was provided but does not contain a valid 'samples' tensor.") - prev_samples = prev_latent["samples"] - - if start_image is None and prev_samples is None: - raise ValueError("SVI node requires either start_image or prev_latent.") - - if start_image is not None: - anchor_latent = vae.encode(start_image[:1, :, :, :3]) - h = anchor_latent.shape[-2] - w = anchor_latent.shape[-1] - else: - h = prev_samples.shape[-2] - w = prev_samples.shape[-1] - anchor_latent = torch.zeros( - [1, latent_channels, 1, h, w], - device=device, - ) - - latent = torch.zeros( - [batch_size, latent_channels, total_latents, h, w], - device=device, - ) - - cond_parts = [anchor_latent] - anchor_t = anchor_latent.shape[2] - motion_t_limit = max(0, total_latents - anchor_t) - - if prev_samples is not None and continue_frames_count > 0 and motion_t_limit > 0: - motion_t = min(continue_frames_count, prev_samples.shape[2]) - motion_latent = prev_samples[:, :, -motion_t:].clone() - - if motion_latent.shape[-2] != h or motion_latent.shape[-1] != w: - raise ValueError("prev_latent spatial size does not match current SVI anchor latent size.") - - if motion_latent.shape[2] > motion_t_limit: - motion_latent = motion_latent[:, :, -motion_t_limit:] - - cond_parts.append(motion_latent) - - image_cond_latent = torch.cat(cond_parts, dim=2) - padding_size = total_latents - image_cond_latent.shape[2] - - if padding_size > 0: - padding = torch.zeros( - [1, latent_channels, padding_size, h, w], - dtype=image_cond_latent.dtype, - device=image_cond_latent.device, - ) - padding = comfy.latent_formats.Wan21().process_out(padding) - image_cond_latent = torch.cat([image_cond_latent, padding], dim=2) - - # SVI uses latent-time masks directly (1,1,T,H,W), unlike frame-domain 4-phase mask reshaping used by non-SVI nodes. - mask_svi = torch.ones( - [1, 1, total_latents, h, w], - device=image_cond_latent.device, - dtype=image_cond_latent.dtype, - ) - mask_svi[:, :, :1] = 0.0 - - conditioning_values = { - "concat_latent_image": image_cond_latent, - "concat_mask": mask_svi, - } - positive_out = node_helpers.conditioning_set_values(positive, conditioning_values) - negative_out = node_helpers.conditioning_set_values(negative, conditioning_values) - - out_latent = {"samples": latent} - return io.NodeOutput(positive_out, negative_out, out_latent) - - -NODE_CLASS_MAPPINGS = { - "Wan22FirstLastImageToVideoSVIMXD": Wan22FirstLastImageToVideoSVIMXD, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "Wan22FirstLastImageToVideoSVIMXD": "WAN 2.2 First/Last I2V SVI MXD", -} diff --git a/web/js/betterCombos.js b/web/js/betterCombos.js index 8b2e099..3cdba10 100644 --- a/web/js/betterCombos.js +++ b/web/js/betterCombos.js @@ -8,6 +8,11 @@ const LORA_LOADER = "LoraLoader|pysssss"; const LOAD_LATENT_WITH_PARAMS = "LoadLatent_WithParams"; const LOAD_LATENT_WITH_PARAMS_MXD_ALIAS = "LoadLatent_WithParams MXD"; const LOAD_LATENT_I2V_MXD = "LoadLatent_I2V_MXD"; +const LOAD_LATENT_I2V_MXD_ALIAS = "LoadLatent_I2V_MXD MXD"; +const LOAD_LATENTS_FROM_FOLDER_WITH_PARAMS = "LoadLatents_FromFolder_WithParams"; +const LOAD_LATENTS_FROM_FOLDER_WITH_PARAMS_MXD_ALIAS = "LoadLatents_FromFolder_WithParams MXD"; +const LOAD_LATENTS_FROM_FOLDER_I2V_MXD = "LoadLatents_FromFolder_I2V_MXD"; +const LOAD_LATENTS_FROM_FOLDER_I2V_MXD_ALIAS = "LoadLatents_FromFolder_I2V_MXD MXD"; const ENABLE_PYSSSS_NODE_ENHANCEMENTS = false; const IMAGE_WIDTH = 384; const IMAGE_HEIGHT = 384; @@ -16,6 +21,11 @@ const NODE_CONFIGS = { [LOAD_LATENT_WITH_PARAMS]: { type: "latents", widgetName: "latent", hasImages: false }, [LOAD_LATENT_WITH_PARAMS_MXD_ALIAS]: { type: "latents", widgetName: "latent", hasImages: false }, [LOAD_LATENT_I2V_MXD]: { type: "latents", widgetName: "latent", hasImages: false }, + [LOAD_LATENT_I2V_MXD_ALIAS]: { type: "latents", widgetName: "latent", hasImages: false }, + [LOAD_LATENTS_FROM_FOLDER_WITH_PARAMS]: { type: "latents", widgetName: "subfolder", hasImages: false }, + [LOAD_LATENTS_FROM_FOLDER_WITH_PARAMS_MXD_ALIAS]: { type: "latents", widgetName: "subfolder", hasImages: false }, + [LOAD_LATENTS_FROM_FOLDER_I2V_MXD]: { type: "latents", widgetName: "subfolder", hasImages: false }, + [LOAD_LATENTS_FROM_FOLDER_I2V_MXD_ALIAS]: { type: "latents", widgetName: "subfolder", hasImages: false }, }; const CONFIG_BY_TYPE = Object.fromEntries( diff --git a/web/js/image_comparer.js b/web/js/image_comparer.js index 290f352..0807e69 100644 --- a/web/js/image_comparer.js +++ b/web/js/image_comparer.js @@ -617,8 +617,50 @@ MxdImageComparer["@comparer_mode"] = { app.registerExtension({ name: "MXD.ImageComparer.Standalone", async beforeRegisterNodeDef(nodeType, nodeData) { - if (NODE_TYPE_STRINGS.has(nodeData.name)) { - MxdImageComparer.setUp(nodeType, nodeData); + if (!NODE_TYPE_STRINGS.has(nodeData.name)) return; + + // Extend nodeType directly instead of replacing it with an anonymous class. + // The old `registerForOverride` approach created `let boundClass = class extends MxdImageComparer {}` + // which causes JS to set boundClass.name = "boundClass" (variable-name inference), and newer + // ComfyUI's LG renderer uses constructor.name as the fallback node title — hence "boundClass". + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + origOnNodeCreated?.apply(this, arguments); + this.imageIndex = 0; + this.imgs = []; + this.serialize_widgets = true; + this.isPointerDown = false; + this.isPointerOver = false; + this.pointerOverPos = [0, 0]; + this.canvasWidget = null; + this._mxdInitialSize = null; + this.properties = this.properties || {}; + if (!this.properties["comparer_mode"]) { + this.properties["comparer_mode"] = "Slide"; + } + this.canvasWidget = this.addCustomWidget( + new MxdImageComparerWidget("mxd_comparer", this) + ); + this.setSize(this.computeSize()); + this.setDirtyCanvas(true, true); + this._mxdInitialSize = [...this.size]; + }; + + for (const name of [ + "onExecuted", "onSerialize", "setIsPointerDown", + "onMouseDown", "onMouseEnter", "onMouseLeave", + "onMouseMove", "getHelp", + ]) { + nodeType.prototype[name] = MxdImageComparer.prototype[name]; + } + + nodeType["@comparer_mode"] = { type: "combo", values: ["Slide", "Click"] }; + }, + + nodeCreated(node) { + // Fix nodes already saved in workflows with the old "boundClass" title. + if (NODE_TYPE_STRINGS.has(node.type) && node.title === "boundClass") { + node.title = node.type; } }, });