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.
This commit is contained in:
Maxed-Out-99
2026-05-04 13:20:06 -07:00
parent e35ec29f4f
commit b0842cfdd7
7 changed files with 777 additions and 212 deletions
+68 -11
View File
@@ -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)
+144 -3
View File
@@ -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",
}
+1 -1
View File
@@ -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)
+510 -63
View File
@@ -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",
-132
View File
@@ -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",
}
+10
View File
@@ -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(
+44 -2
View File
@@ -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;
}
},
});