From 181e818f66212e17c0fcab9c75d166ac91a5f6ef Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Sat, 7 Feb 2026 19:55:19 -0800 Subject: [PATCH] Make comfy_api optional & guard node registration Wrap direct comfy_api imports in try/except and introduce HAVE_COMFY_API flags so the package can load without comfy_api present. Update __init__.py to dynamically and safely import node modules and merge NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS. Conditionally define and register nodes that depend on comfy_api (Qwen image-edit nodes, WAN 2.2 video nodes, Combine/Load/Save/Preview video nodes) so they are only available when comfy_api is present. Add helper to auto-add " MXD" aliases for node names and to merge display mappings. Bump package version to 1.6.9 and add diagnostic prints when comfy_api imports fail. --- __init__.py | 41 +-- maxedoutnodes.py | 326 +++++++++++---------- pyproject.toml | 2 +- wan22nodes.py | 730 +++++++++++++++++++++++++---------------------- 4 files changed, 586 insertions(+), 513 deletions(-) diff --git a/__init__.py b/__init__.py index 2b05c56..89b8e0e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,28 +1,29 @@ -from .maxedoutnodes import ( - NODE_CLASS_MAPPINGS as MXD_NODE_CLASS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as MXD_NODE_DISPLAY_NAME_MAPPINGS, -) -from .mediacomparers import ( - NODE_CLASS_MAPPINGS as MEDIA_NODE_CLASS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as MEDIA_NODE_DISPLAY_NAME_MAPPINGS, -) -from .wan22nodes import ( - NODE_CLASS_MAPPINGS as WAN22_NODE_CLASS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as WAN22_NODE_DISPLAY_NAME_MAPPINGS, -) +import importlib WEB_DIRECTORY = "web" -# Merge both sets into one -NODE_CLASS_MAPPINGS = {} -NODE_CLASS_MAPPINGS.update(MXD_NODE_CLASS_MAPPINGS) -NODE_CLASS_MAPPINGS.update(MEDIA_NODE_CLASS_MAPPINGS) -NODE_CLASS_MAPPINGS.update(WAN22_NODE_CLASS_MAPPINGS) +def _safe_import(module_name: str): + try: + return importlib.import_module(f".{module_name}", __name__) + except Exception as e: + print(f"[ComfyUI-MaxedOut] Failed to import '{module_name}': {e}") + return None +def _get_mappings(mod): + if mod is None: + return {}, {} + class_map = getattr(mod, "NODE_CLASS_MAPPINGS", {}) or {} + display_map = getattr(mod, "NODE_DISPLAY_NAME_MAPPINGS", {}) or {} + return class_map, display_map + +NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} -NODE_DISPLAY_NAME_MAPPINGS.update(MXD_NODE_DISPLAY_NAME_MAPPINGS) -NODE_DISPLAY_NAME_MAPPINGS.update(MEDIA_NODE_DISPLAY_NAME_MAPPINGS) -NODE_DISPLAY_NAME_MAPPINGS.update(WAN22_NODE_DISPLAY_NAME_MAPPINGS) + +for _name in ("maxedoutnodes", "mediacomparers", "wan22nodes"): + _mod = _safe_import(_name) + _class_map, _display_map = _get_mappings(_mod) + NODE_CLASS_MAPPINGS.update(_class_map) + NODE_DISPLAY_NAME_MAPPINGS.update(_display_map) __all__ = [ "NODE_CLASS_MAPPINGS", diff --git a/maxedoutnodes.py b/maxedoutnodes.py index c8352cd..98ccd66 100644 --- a/maxedoutnodes.py +++ b/maxedoutnodes.py @@ -4,7 +4,13 @@ import torch.nn.functional as F from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict import numpy as np from PIL import Image, ImageOps, ImageSequence, ImageFilter -from comfy_api.latest import io +try: + from comfy_api.latest import io + HAVE_COMFY_API = True +except Exception as _e: + io = None + HAVE_COMFY_API = False + print(f"[ComfyUI-MaxedOut] comfy_api not available in maxedoutnodes: {_e}") ######################################################################################################################## # Flux Empty Latent Image (SD3-compatible) @@ -443,164 +449,163 @@ class PromptWithGuidance(ComfyNodeABC): return (conditioning,) ######################################################################################################################## -class QwenImageEditSingleMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="QwenImageEditSingleMXD", - display_name="Qwen Image Edit + Latent MXD", - category="MXD/conditioning", - description="Encode prompt/image and output a matching empty latent.", - inputs=[ - io.Clip.Input("clip"), - io.String.Input("prompt", multiline=True, dynamic_prompts=True), - io.Vae.Input("vae", optional=True), - io.Image.Input("image", optional=True), - io.Int.Input("batch_size", default=1, min=1, max=4096), - ], - outputs=[ - io.Conditioning.Output(), - io.Latent.Output(), # New Output - ], - ) - - @classmethod - def execute(cls, clip, prompt, vae=None, image=None, batch_size=1) -> io.NodeOutput: - ref_latents = [] - images_vl = [] - llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" - image_prompt = "" - - # Default fallback size if no image is provided (1024x1024) - final_width, final_height = 1024, 1024 - - if image is not None: - samples = image.movedim(-1, 1) - - # --- VISION SCALING (384px area) --- - total_vl = int(384 * 384) - scale_vl = math.sqrt(total_vl / (samples.shape[3] * samples.shape[2])) - width_vl = round(samples.shape[3] * scale_vl) - height_vl = round(samples.shape[2] * scale_vl) - - s_vl = comfy.utils.common_upscale(samples, width_vl, height_vl, "area", "disabled") - images_vl.append(s_vl.movedim(1, -1)) - - # --- LATENT/VAE SCALING (1024px area) --- - total_lat = int(1024 * 1024) - scale_lat = math.sqrt(total_lat / (samples.shape[3] * samples.shape[2])) - # Calculate final dimensions to be multiples of 8 - final_width = round(samples.shape[3] * scale_lat / 8.0) * 8 - final_height = round(samples.shape[2] * scale_lat / 8.0) * 8 - - if vae is not None: - s_lat = comfy.utils.common_upscale(samples, final_width, final_height, "area", "disabled") - ref_latents.append(vae.encode(s_lat.movedim(1, -1)[:, :, :, :3])) - - image_prompt += "Picture 1: <|vision_start|><|image_pad|><|vision_end|>" - - # 1. Generate the Empty Latent (SD3 Style: 16 channels, 1/8th resolution) - # This replaces the need for the separate EmptySD3LatentImage node - latent_tensor = torch.zeros( - [batch_size, 16, final_height // 8, final_width // 8], - device=comfy.model_management.intermediate_device() - ) - latent_output = {"samples": latent_tensor} - - # 2. Process Conditioning - tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template) - conditioning = clip.encode_from_tokens_scheduled(tokens) - - if len(ref_latents) > 0: - conditioning = node_helpers.conditioning_set_values( - conditioning, - {"reference_latents": ref_latents}, - append=True, +if HAVE_COMFY_API: + class QwenImageEditSingleMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="QwenImageEditSingleMXD", + display_name="Qwen Image Edit + Latent MXD", + category="MXD/conditioning", + description="Encode prompt/image and output a matching empty latent.", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("prompt", multiline=True, dynamic_prompts=True), + io.Vae.Input("vae", optional=True), + io.Image.Input("image", optional=True), + io.Int.Input("batch_size", default=1, min=1, max=4096), + ], + outputs=[ + io.Conditioning.Output(), + io.Latent.Output(), # New Output + ], ) - return io.NodeOutput(conditioning, latent_output) - + @classmethod + def execute(cls, clip, prompt, vae=None, image=None, batch_size=1) -> io.NodeOutput: + ref_latents = [] + images_vl = [] + llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" + image_prompt = "" - -######################################################################################################################## -class QwenImageEditTripleMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="QwenImageEditTripleMXD", - display_name="Qwen Image Edit Prompt MXD (Triple)", - category="advanced/conditioning", - inputs=[ - io.Clip.Input("clip"), - io.String.Input("prompt", multiline=True, dynamic_prompts=True), - io.Vae.Input("vae", optional=True), - io.Image.Input("image1", optional=True), - io.Image.Input("image2", optional=True), - io.Image.Input("image3", optional=True), - io.Int.Input("batch_size", default=1, min=1, max=4096), - ], - outputs=[ - io.Conditioning.Output(), - io.Latent.Output(), - ], - ) + # Default fallback size if no image is provided (1024x1024) + final_width, final_height = 1024, 1024 - @classmethod - def execute(cls, clip, prompt, vae=None, image1=None, image2=None, image3=None, batch_size=1) -> io.NodeOutput: - ref_latents = [] - images = [image1, image2, image3] - images_vl = [] - llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" - image_prompt = "" - - # Default fallback - latent_width = 1024 - latent_height = 1024 - - for i, image in enumerate(images): if image is not None: samples = image.movedim(-1, 1) - - # 1. VL Model Scaling (LLM Vision) + + # --- VISION SCALING (384px area) --- total_vl = int(384 * 384) - scale_by_vl = math.sqrt(total_vl / (samples.shape[3] * samples.shape[2])) - width_vl = round(samples.shape[3] * scale_by_vl) - height_vl = round(samples.shape[2] * scale_by_vl) + scale_vl = math.sqrt(total_vl / (samples.shape[3] * samples.shape[2])) + width_vl = round(samples.shape[3] * scale_vl) + height_vl = round(samples.shape[2] * scale_vl) + s_vl = comfy.utils.common_upscale(samples, width_vl, height_vl, "area", "disabled") images_vl.append(s_vl.movedim(1, -1)) - # 2. VAE Scaling (Synchronized to 16-step for SD3 compatibility) + # --- LATENT/VAE SCALING (1024px area) --- + total_lat = int(1024 * 1024) + scale_lat = math.sqrt(total_lat / (samples.shape[3] * samples.shape[2])) + # Calculate final dimensions to be multiples of 8 + final_width = round(samples.shape[3] * scale_lat / 8.0) * 8 + final_height = round(samples.shape[2] * scale_lat / 8.0) * 8 + if vae is not None: - total_ref = int(1024 * 1024) - scale_by_ref = math.sqrt(total_ref / (samples.shape[3] * samples.shape[2])) - - # Pixels as multiple of 16 ensures Latent (Pixels/8) is always even - width_ref = round(samples.shape[3] * scale_by_ref / 16.0) * 16 - height_ref = round(samples.shape[2] * scale_by_ref / 16.0) * 16 + s_lat = comfy.utils.common_upscale(samples, final_width, final_height, "area", "disabled") + ref_latents.append(vae.encode(s_lat.movedim(1, -1)[:, :, :, :3])) - if i == 0: - latent_width = width_ref - latent_height = height_ref + image_prompt += "Picture 1: <|vision_start|><|image_pad|><|vision_end|>" - s_ref = comfy.utils.common_upscale(samples, width_ref, height_ref, "area", "disabled") - ref_latents.append(vae.encode(s_ref.movedim(1, -1)[:, :, :, :3])) + # 1. Generate the Empty Latent (SD3 Style: 16 channels, 1/8th resolution) + # This replaces the need for the separate EmptySD3LatentImage node + latent_tensor = torch.zeros( + [batch_size, 16, final_height // 8, final_width // 8], + device=comfy.model_management.intermediate_device() + ) + latent_output = {"samples": latent_tensor} - image_prompt += "Picture {}: <|vision_start|><|image_pad|><|vision_end|>".format(i + 1) + # 2. Process Conditioning + tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template) + conditioning = clip.encode_from_tokens_scheduled(tokens) + + if len(ref_latents) > 0: + conditioning = node_helpers.conditioning_set_values( + conditioning, + {"reference_latents": ref_latents}, + append=True, + ) + + return io.NodeOutput(conditioning, latent_output) + + ######################################################################################################################## + class QwenImageEditTripleMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="QwenImageEditTripleMXD", + display_name="Qwen Image Edit Prompt MXD (Triple)", + category="advanced/conditioning", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("prompt", multiline=True, dynamic_prompts=True), + io.Vae.Input("vae", optional=True), + io.Image.Input("image1", optional=True), + io.Image.Input("image2", optional=True), + io.Image.Input("image3", optional=True), + io.Int.Input("batch_size", default=1, min=1, max=4096), + ], + outputs=[ + io.Conditioning.Output(), + io.Latent.Output(), + ], + ) + + @classmethod + def execute(cls, clip, prompt, vae=None, image1=None, image2=None, image3=None, batch_size=1) -> io.NodeOutput: + ref_latents = [] + images = [image1, image2, image3] + images_vl = [] + llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" + image_prompt = "" + + # Default fallback + latent_width = 1024 + latent_height = 1024 + + for i, image in enumerate(images): + if image is not None: + samples = image.movedim(-1, 1) + + # 1. VL Model Scaling (LLM Vision) + total_vl = int(384 * 384) + scale_by_vl = math.sqrt(total_vl / (samples.shape[3] * samples.shape[2])) + width_vl = round(samples.shape[3] * scale_by_vl) + height_vl = round(samples.shape[2] * scale_by_vl) + s_vl = comfy.utils.common_upscale(samples, width_vl, height_vl, "area", "disabled") + images_vl.append(s_vl.movedim(1, -1)) + + # 2. VAE Scaling (Synchronized to 16-step for SD3 compatibility) + if vae is not None: + total_ref = int(1024 * 1024) + scale_by_ref = math.sqrt(total_ref / (samples.shape[3] * samples.shape[2])) + + # Pixels as multiple of 16 ensures Latent (Pixels/8) is always even + width_ref = round(samples.shape[3] * scale_by_ref / 16.0) * 16 + height_ref = round(samples.shape[2] * scale_by_ref / 16.0) * 16 + + if i == 0: + latent_width = width_ref + latent_height = height_ref + + s_ref = comfy.utils.common_upscale(samples, width_ref, height_ref, "area", "disabled") + ref_latents.append(vae.encode(s_ref.movedim(1, -1)[:, :, :, :3])) + + image_prompt += "Picture {}: <|vision_start|><|image_pad|><|vision_end|>".format(i + 1) + + # Process tokens and conditioning + tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template) + conditioning = clip.encode_from_tokens_scheduled(tokens) + + if len(ref_latents) > 0: + conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True) + + # Create Output Latent + latent = torch.zeros([batch_size, 16, latent_height // 8, latent_width // 8], device=comfy.model_management.intermediate_device()) + + # FIXED: Return outputs positionally to match the schema defined above + # Output 1: Conditioning, Output 2: Latent Dictionary + return io.NodeOutput(conditioning, {"samples": latent}) - # Process tokens and conditioning - tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template) - conditioning = clip.encode_from_tokens_scheduled(tokens) - - if len(ref_latents) > 0: - conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True) - - # Create Output Latent - latent = torch.zeros([batch_size, 16, latent_height // 8, latent_width // 8], device=comfy.model_management.intermediate_device()) - - # FIXED: Return outputs positionally to match the schema defined above - # Output 1: Conditioning, Output 2: Latent Dictionary - return io.NodeOutput(conditioning, {"samples": latent}) - ######################################################################################################################## class FluxResolutionMatcher: DESCRIPTION = """Match the closest Flux resolution and orientation for the input image.""" @@ -1408,8 +1413,6 @@ NODE_CLASS_MAPPINGS = { "Image Scale To Total Pixels (SDXL Safe)": SDXLImageScaleToTotalPixelsSafe, "Flux Image Scale To Total Pixels (Flux Safe)": FluxImageScaleToTotalPixelsSafe, "Prompt With Guidance (Flux)": PromptWithGuidance, - "QwenImageEditSingleMXD": QwenImageEditSingleMXD, - "QwenImageEditTripleMXD": QwenImageEditTripleMXD, "FluxResolutionMatcher": FluxResolutionMatcher, "SDXLResolutionMatcher": SDXLResolutionMatcher, "LatentHalfMasks": LatentHalfMasks, @@ -1424,6 +1427,12 @@ NODE_CLASS_MAPPINGS = { "Dummy Node MXD": DummyNodeMXD, } +if HAVE_COMFY_API: + NODE_CLASS_MAPPINGS.update({ + "QwenImageEditSingleMXD": QwenImageEditSingleMXD, + "QwenImageEditTripleMXD": QwenImageEditTripleMXD, + }) + NODE_DISPLAY_NAME_MAPPINGS = { "Flux Empty Latent Image": "Flux Empty Latent Image MXD", "Flux 2 Empty Latent Image": "Flux 2 Empty Latent Image MXD", @@ -1432,8 +1441,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Image Scale To Total Pixels (SDXL Safe)": "Scale SDXL Image MXD", "Flux Image Scale To Total Pixels (Flux Safe)": "Scale Flux Image MXD", "Prompt With Guidance (Flux)": "Prompt with Flux Guidance MXD", - "QwenImageEditSingleMXD": "Qwen Image Edit + Latent MXD", - "QwenImageEditTripleMXD": "Qwen Image Edit Prompt MXD (Triple)", "FluxResolutionMatcher": "Flux Resolution Matcher MXD", "SDXLResolutionMatcher": "SDXL Resolution Matcher MXD", "LatentHalfMasks": "Latent to L/R Masks MXD", @@ -1447,3 +1454,26 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Save Image MXD": "Save Image MXD", "Dummy Node MXD": "Dummy Node MXD", } + +if HAVE_COMFY_API: + NODE_DISPLAY_NAME_MAPPINGS.update({ + "QwenImageEditSingleMXD": "Qwen Image Edit + Latent MXD", + "QwenImageEditTripleMXD": "Qwen Image Edit Prompt MXD (Triple)", + }) + +def _add_mxd_aliases(class_map, display_map): + alias_sources = {} + for key in list(class_map.keys()): + if "MXD" in key.upper(): + continue + alias = f"{key} MXD" + if alias in class_map: + continue + class_map[alias] = class_map[key] + alias_sources[alias] = key + for alias, source in alias_sources.items(): + if alias not in display_map: + display_map[alias] = display_map.get(source, alias) + return alias_sources + +_add_mxd_aliases(NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS) diff --git a/pyproject.toml b/pyproject.toml index a820929..a1ba8e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "MaxedOut" description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)" -version = "1.6.8" +version = "1.6.9" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/wan22nodes.py b/wan22nodes.py index 40e0dcb..69132bd 100644 --- a/wan22nodes.py +++ b/wan22nodes.py @@ -13,10 +13,23 @@ from nodes import KSamplerAdvanced import node_helpers, nodes # Comfy API -from comfy_api.latest import io, ui -from comfy_api.input import VideoInput -from comfy_api.input_impl import VideoFromFile, VideoFromComponents -from comfy_api.util import VideoComponents, VideoContainer, VideoCodec +try: + from comfy_api.latest import io, ui + from comfy_api.input import VideoInput + from comfy_api.input_impl import VideoFromFile, VideoFromComponents + from comfy_api.util import VideoComponents, VideoContainer, VideoCodec + HAVE_COMFY_API = True +except Exception as _e: + io = None + ui = None + VideoInput = None + VideoFromFile = None + VideoFromComponents = None + VideoComponents = None + VideoContainer = None + VideoCodec = None + HAVE_COMFY_API = False + print(f"[ComfyUI-MaxedOut] comfy_api not available in wan22nodes: {_e}") from server import PromptServer from aiohttp import web @@ -1104,72 +1117,72 @@ class wan22EmptyHunyuanLatentVideoMXD: ) return ({"samples": latent},) # ---------- WAN 2.2 Image to Video (no scaling; expects pre-sized input) ---------- +if HAVE_COMFY_API: + class Wan22ImageToVideoMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="Wan22ImageToVideoMXD", + display_name="WAN 2.2 Image to Video MXD", + category="conditioning/video_models", + description="WAN 2.2 image to video without scaling or CLIP vision.", + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Int.Input("length", default=81, min=1, max=16384, step=4), + io.Int.Input("batch_size", default=1, min=1, max=4096), + io.Image.Input("start_image", optional=False), + ], + outputs=[ + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent"), + ], + ) -class Wan22ImageToVideoMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="Wan22ImageToVideoMXD", - display_name="WAN 2.2 Image to Video MXD", - category="conditioning/video_models", - description="WAN 2.2 image to video without scaling or CLIP vision.", - inputs=[ - io.Conditioning.Input("positive"), - io.Conditioning.Input("negative"), - io.Vae.Input("vae"), - io.Int.Input("length", default=81, min=1, max=16384, step=4), - io.Int.Input("batch_size", default=1, min=1, max=4096), - io.Image.Input("start_image", optional=False), - ], - 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) -> io.NodeOutput: + if start_image is None: + raise ValueError("start_image must be provided (already pre-sized).") - @classmethod - def execute(cls, positive, negative, vae, length, batch_size, start_image) -> io.NodeOutput: - if start_image is None: - raise ValueError("start_image must be provided (already pre-sized).") + frames_in, ih, iw, ch = start_image.shape + frames_used = min(frames_in, length) + t = ((length - 1) // 4) + 1 - frames_in, ih, iw, ch = start_image.shape - frames_used = min(frames_in, length) - t = ((length - 1) // 4) + 1 + latent = torch.zeros( + [batch_size, 16, t, ih // 8, iw // 8], + device=comfy.model_management.intermediate_device() + ) - latent = torch.zeros( - [batch_size, 16, t, ih // 8, iw // 8], - device=comfy.model_management.intermediate_device() - ) + # create placeholder image tensor + image = torch.ones( + (length, ih, iw, ch), + device=start_image.device, + dtype=start_image.dtype + ) * 0.5 + image[:frames_used] = start_image[:frames_used] - # create placeholder image tensor - image = torch.ones( - (length, ih, iw, ch), - device=start_image.device, - dtype=start_image.dtype - ) * 0.5 - image[:frames_used] = start_image[:frames_used] + # encode using VAE + concat_latent_image = vae.encode(image[:, :, :, :3]) - # encode using VAE - concat_latent_image = vae.encode(image[:, :, :, :3]) + # mask zeros out the frames used + mask = torch.ones( + (1, 1, t, concat_latent_image.shape[-2], concat_latent_image.shape[-1]), + device=image.device, + dtype=image.dtype + ) + mask[:, :, :((frames_used - 1) // 4) + 1] = 0.0 - # mask zeros out the frames used - mask = torch.ones( - (1, 1, t, concat_latent_image.shape[-2], concat_latent_image.shape[-1]), - device=image.device, - dtype=image.dtype - ) - mask[:, :, :((frames_used - 1) // 4) + 1] = 0.0 + positive = node_helpers.conditioning_set_values( + positive, {"concat_latent_image": concat_latent_image, "concat_mask": mask} + ) + negative = node_helpers.conditioning_set_values( + negative, {"concat_latent_image": concat_latent_image, "concat_mask": mask} + ) - positive = node_helpers.conditioning_set_values( - positive, {"concat_latent_image": concat_latent_image, "concat_mask": mask} - ) - negative = node_helpers.conditioning_set_values( - negative, {"concat_latent_image": concat_latent_image, "concat_mask": mask} - ) - - out_latent = {"samples": latent} - return io.NodeOutput(positive, negative, out_latent) + out_latent = {"samples": latent} + return io.NodeOutput(positive, negative, out_latent) # ---- Canonical WAN 2.2 buckets ---- BUCKETS_480 = [(832,480), (480,832), (624,624)] # 16:9, 9:16, 1:1 @@ -1449,260 +1462,262 @@ class Frames_Remove_From_Start_MXD: return (frames_after,) -class CombineVideos_MXD: - """ - Combine two VIDEO inputs end-to-end (sequentially). - """ +if HAVE_COMFY_API: + class CombineVideos_MXD: + """ + Combine two VIDEO inputs end-to-end (sequentially). + """ - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "front_video": ("VIDEO", {"tooltip": "The first video (plays first)"}), - "back_video": ("VIDEO", {"tooltip": "The second video (plays after the first)"}), - }, - } - - RETURN_TYPES = ("VIDEO",) - RETURN_NAMES = ("video",) - FUNCTION = "combine" - CATEGORY = "MXD/video" - - def combine(self, front_video, back_video): - comp_a = front_video.get_components() - comp_b = back_video.get_components() - - # Check frame rate consistency - if comp_a.frame_rate != comp_b.frame_rate: - raise ValueError(f"FPS mismatch: {comp_a.frame_rate} vs {comp_b.frame_rate}") - - # โœ… Correct way: concatenate frame tensors along batch/time dimension (dim=0) - frames_a = torch.stack(comp_a.images) if isinstance(comp_a.images, list) else comp_a.images - frames_b = torch.stack(comp_b.images) if isinstance(comp_b.images, list) else comp_b.images - combined_images = torch.cat([frames_a, frames_b], dim=0) - - # โœ… Combine audio sequentially - combined_audio = None - if comp_a.audio is not None or comp_b.audio is not None: - audio_a = comp_a.audio if comp_a.audio is not None else torch.zeros((1, 0)) - audio_b = comp_b.audio if comp_b.audio is not None else torch.zeros((1, 0)) - combined_audio = torch.cat([audio_a, audio_b], dim=1) - - - - combined_video = VideoFromComponents( - VideoComponents( - images=combined_images, - audio=combined_audio, - frame_rate=comp_a.frame_rate, - ) - ) - - return (combined_video,) - -# ---------- Load Video MXD (video-only picker with refresh) ---------- -class LoadVideoMXD: - """Load a video from /input with a refresh button (videos only).""" - - CATEGORY = "image/video" - FUNCTION = "load" - RETURN_TYPES = ("VIDEO", "STRING") - RETURN_NAMES = ("video", "video_path") - TITLE = "Load Video MXD" - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "file": ("COMBO", { - # Only allow video uploads in the picker - "video_upload": True, - # Custom route that returns ONLY videos in /input - "remote": { - "route": "/mxd/videos/input", - "refresh_button": True, - "control_after_refresh": "first", - }, - }), + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "front_video": ("VIDEO", {"tooltip": "The first video (plays first)"}), + "back_video": ("VIDEO", {"tooltip": "The second video (plays after the first)"}), + }, } - } - # --- helpers -------------------------------------------------------------- + RETURN_TYPES = ("VIDEO",) + RETURN_NAMES = ("video",) + FUNCTION = "combine" + CATEGORY = "MXD/video" - @staticmethod - def _resolve_video_path(file: str) -> str: - """ - Try to resolve `file` in a backwards-compatible way: - 1. If it's an annotated path, let folder_paths handle it. - 2. Otherwise treat it as relative to the input directory. - """ - # 1) Try annotated style (old workflows / uploads) - try: - return folder_paths.get_annotated_filepath(file) - except Exception: - pass + def combine(self, front_video, back_video): + comp_a = front_video.get_components() + comp_b = back_video.get_components() - # 2) Fall back to /input relative - base = folder_paths.get_input_directory() - candidate = os.path.join(base, file) - if os.path.isfile(candidate): + # Check frame rate consistency + if comp_a.frame_rate != comp_b.frame_rate: + raise ValueError(f"FPS mismatch: {comp_a.frame_rate} vs {comp_b.frame_rate}") + + # โœ… Correct way: concatenate frame tensors along batch/time dimension (dim=0) + frames_a = torch.stack(comp_a.images) if isinstance(comp_a.images, list) else comp_a.images + frames_b = torch.stack(comp_b.images) if isinstance(comp_b.images, list) else comp_b.images + combined_images = torch.cat([frames_a, frames_b], dim=0) + + # โœ… Combine audio sequentially + combined_audio = None + if comp_a.audio is not None or comp_b.audio is not None: + audio_a = comp_a.audio if comp_a.audio is not None else torch.zeros((1, 0)) + audio_b = comp_b.audio if comp_b.audio is not None else torch.zeros((1, 0)) + combined_audio = torch.cat([audio_a, audio_b], dim=1) + + + + combined_video = VideoFromComponents( + VideoComponents( + images=combined_images, + audio=combined_audio, + frame_rate=comp_a.frame_rate, + ) + ) + + return (combined_video,) + + # ---------- Load Video MXD (video-only picker with refresh) ---------- + class LoadVideoMXD: + """Load a video from /input with a refresh button (videos only).""" + + CATEGORY = "image/video" + FUNCTION = "load" + RETURN_TYPES = ("VIDEO", "STRING") + RETURN_NAMES = ("video", "video_path") + TITLE = "Load Video MXD" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "file": ("COMBO", { + # Only allow video uploads in the picker + "video_upload": True, + # Custom route that returns ONLY videos in /input + "remote": { + "route": "/mxd/videos/input", + "refresh_button": True, + "control_after_refresh": "first", + }, + }), + } + } + + # --- helpers -------------------------------------------------------------- + + @staticmethod + def _resolve_video_path(file: str) -> str: + """ + Try to resolve `file` in a backwards-compatible way: + 1. If it's an annotated path, let folder_paths handle it. + 2. Otherwise treat it as relative to the input directory. + """ + # 1) Try annotated style (old workflows / uploads) + try: + return folder_paths.get_annotated_filepath(file) + except Exception: + pass + + # 2) Fall back to /input relative + base = folder_paths.get_input_directory() + candidate = os.path.join(base, file) + if os.path.isfile(candidate): + return candidate + + # If all else fails, just return what we got (will error later) return candidate - # If all else fails, just return what we got (will error later) - return candidate + @staticmethod + def _is_video_file(path: str) -> bool: + _, ext = os.path.splitext(path) + return ext.lower() in VIDEO_EXTS - @staticmethod - def _is_video_file(path: str) -> bool: - _, ext = os.path.splitext(path) - return ext.lower() in VIDEO_EXTS + # --- main function -------------------------------------------------------- - # --- main function -------------------------------------------------------- + def load(self, file: str): + video_path = self._resolve_video_path(file) - def load(self, file: str): - video_path = self._resolve_video_path(file) + if not os.path.isfile(video_path): + raise FileNotFoundError(f"[LoadVideoMXD] File not found: {video_path}") - if not os.path.isfile(video_path): - raise FileNotFoundError(f"[LoadVideoMXD] File not found: {video_path}") + if not self._is_video_file(video_path): + raise ValueError(f"[LoadVideoMXD] Not a video file: {video_path}") - if not self._is_video_file(video_path): - raise ValueError(f"[LoadVideoMXD] Not a video file: {video_path}") + print(f"[LoadVideoMXD] Loaded exactly: {video_path}") + return (VideoFromFile(video_path), video_path) - print(f"[LoadVideoMXD] Loaded exactly: {video_path}") - return (VideoFromFile(video_path), video_path) + # --- nice-to-haves -------------------------------------------------------- - # --- nice-to-haves -------------------------------------------------------- + @classmethod + def IS_CHANGED(cls, file: str): + try: + p = cls._resolve_video_path(file) + return os.path.getmtime(p) + except Exception: + return 0 - @classmethod - def IS_CHANGED(cls, file: str): - try: - p = cls._resolve_video_path(file) - return os.path.getmtime(p) - except Exception: - return 0 + @classmethod + def VALIDATE_INPUTS(cls, file: str): + # First, try the annotated path (for backwards compat) + if folder_paths.exists_annotated_filepath(file): + resolved = folder_paths.get_annotated_filepath(file) + if not cls._is_video_file(resolved): + return f"This node only accepts video files ({', '.join(sorted(VIDEO_EXTS))})." + return True - @classmethod - def VALIDATE_INPUTS(cls, file: str): - # First, try the annotated path (for backwards compat) - if folder_paths.exists_annotated_filepath(file): - resolved = folder_paths.get_annotated_filepath(file) - if not cls._is_video_file(resolved): - return f"This node only accepts video files ({', '.join(sorted(VIDEO_EXTS))})." - return True + # Then, try treating it as /input-relative + base = folder_paths.get_input_directory() + candidate = os.path.join(base, file) + if os.path.isfile(candidate): + if not cls._is_video_file(candidate): + return f"This node only accepts video files ({', '.join(sorted(VIDEO_EXTS))})." + return True - # Then, try treating it as /input-relative - base = folder_paths.get_input_directory() - candidate = os.path.join(base, file) - if os.path.isfile(candidate): - if not cls._is_video_file(candidate): - return f"This node only accepts video files ({', '.join(sorted(VIDEO_EXTS))})." - return True - - return f"Invalid video file: {file}" + return f"Invalid video file: {file}" -# ---------- Save Video MXD (auto-increment clean filenames) ---------- -class SaveVideoMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="SaveVideoMXD", - display_name="Save Video MXD", - category="image/video", - description="Save a new version next to the original with clean counters.", - inputs=[ - io.Video.Input("video"), - io.String.Input("video_path"), - io.Combo.Input("save_to_outputs", options=[False, True], default=False), - io.Combo.Input("format", options=VideoContainer.as_input(), default="auto"), - io.Combo.Input("codec", options=VideoCodec.as_input(), default="auto"), - ], - outputs=[], - hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], - is_output_node=True, - ) + # ---------- Save Video MXD (auto-increment clean filenames) ---------- + class SaveVideoMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="SaveVideoMXD", + display_name="Save Video MXD", + category="image/video", + description="Save a new version next to the original with clean counters.", + inputs=[ + io.Video.Input("video"), + io.String.Input("video_path"), + io.Combo.Input("save_to_outputs", options=[False, True], default=False), + io.Combo.Input("format", options=VideoContainer.as_input(), default="auto"), + io.Combo.Input("codec", options=VideoCodec.as_input(), default="auto"), + ], + outputs=[], + hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], + is_output_node=True, + ) - @classmethod - def execute(cls, video: VideoInput, video_path: str, save_to_outputs: bool, format: str, codec: str): - base_dir, base_filename = os.path.split(video_path) - base_name, ext = os.path.splitext(base_filename) + @classmethod + def execute(cls, video: VideoInput, video_path: str, save_to_outputs: bool, format: str, codec: str): + base_dir, base_filename = os.path.split(video_path) + base_name, ext = os.path.splitext(base_filename) - # ๐Ÿงน Clean trailing counters like "__001__002" โ†’ remove them all - base_clean = re.sub(r'(__\d+)+$', '', base_name) + # ๐Ÿงน Clean trailing counters like "__001__002" โ†’ remove them all + base_clean = re.sub(r'(__\d+)+$', '', base_name) - # ๐Ÿงฎ Find the next available counter - pattern = re.compile(rf"^{re.escape(base_clean)}__(\d+){re.escape(ext)}$") - existing = [ - int(m.group(1)) - for f in os.listdir(base_dir) - if (m := pattern.match(f)) - ] - next_counter = max(existing, default=0) + 1 + # ๐Ÿงฎ Find the next available counter + pattern = re.compile(rf"^{re.escape(base_clean)}__(\d+){re.escape(ext)}$") + existing = [ + int(m.group(1)) + for f in os.listdir(base_dir) + if (m := pattern.match(f)) + ] + next_counter = max(existing, default=0) + 1 - new_filename = f"{base_clean}__{next_counter:03d}{ext}" - save_path = os.path.join(base_dir, new_filename) + new_filename = f"{base_clean}__{next_counter:03d}{ext}" + save_path = os.path.join(base_dir, new_filename) - # ๐Ÿ’พ Metadata - saved_metadata = None - if not args.disable_metadata: - metadata = {} - if cls.hidden.extra_pnginfo is not None: - metadata.update(cls.hidden.extra_pnginfo) - if cls.hidden.prompt is not None: - metadata["prompt"] = cls.hidden.prompt - if metadata: - saved_metadata = metadata + # ๐Ÿ’พ Metadata + saved_metadata = None + if not args.disable_metadata: + metadata = {} + if cls.hidden.extra_pnginfo is not None: + metadata.update(cls.hidden.extra_pnginfo) + if cls.hidden.prompt is not None: + metadata["prompt"] = cls.hidden.prompt + if metadata: + saved_metadata = metadata - # ๐Ÿš€ Save main copy - video.save_to(save_path, format=format, codec=codec, metadata=saved_metadata) + # ๐Ÿš€ Save main copy + video.save_to(save_path, format=format, codec=codec, metadata=saved_metadata) - # ๐Ÿชฃ Optional copy to outputs folder - if save_to_outputs: - out_dir = folder_paths.get_output_directory() + # ๐Ÿชฃ Optional copy to outputs folder + if save_to_outputs: + out_dir = folder_paths.get_output_directory() + os.makedirs(out_dir, exist_ok=True) + alt_path = os.path.join(out_dir, new_filename) + video.save_to(alt_path, format=format, codec=codec, metadata=saved_metadata) + print(f"[SaveVideoMXD] Also saved copy to outputs: {alt_path}") + + print(f"[SaveVideoMXD] Saved clean new version: {new_filename}") + + rel_folder = os.path.relpath(base_dir, folder_paths.get_output_directory()) + return io.NodeOutput( + ui=ui.PreviewVideo([ + ui.SavedResult(new_filename, rel_folder, io.FolderType.output) + ]) + ) + + class PreviewVideoMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="PreviewVideoMXD", + display_name="Preview Video MXD", + category="image/video", + description="Preview a video without saving output.", + inputs=[ + io.Video.Input("input_video", tooltip="Video to preview."), + ], + outputs=[ + io.Video.Output("output_video", tooltip="Passes the same video forward."), + ], + ) + + @classmethod + def execute(cls, input_video: VideoInput): + # Save a temporary H264 file so ComfyUI has something to preview + out_dir = os.path.join(folder_paths.get_output_directory(), "previews") os.makedirs(out_dir, exist_ok=True) - alt_path = os.path.join(out_dir, new_filename) - video.save_to(alt_path, format=format, codec=codec, metadata=saved_metadata) - print(f"[SaveVideoMXD] Also saved copy to outputs: {alt_path}") - print(f"[SaveVideoMXD] Saved clean new version: {new_filename}") + preview_path = os.path.join(out_dir, "preview_temp.mp4") + input_video.save_to(preview_path, format="mp4", codec="h264") - rel_folder = os.path.relpath(base_dir, folder_paths.get_output_directory()) - return io.NodeOutput( - ui=ui.PreviewVideo([ - ui.SavedResult(new_filename, rel_folder, io.FolderType.output) - ]) - ) + # โœ… Return the raw video object (not a tuple) + return io.NodeOutput( + input_video, + ui=ui.PreviewVideo([ + ui.SavedResult("preview_temp.mp4", "previews", io.FolderType.output) + ]) + ) -class PreviewVideoMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="PreviewVideoMXD", - display_name="Preview Video MXD", - category="image/video", - description="Preview a video without saving output.", - inputs=[ - io.Video.Input("input_video", tooltip="Video to preview."), - ], - outputs=[ - io.Video.Output("output_video", tooltip="Passes the same video forward."), - ], - ) - - @classmethod - def execute(cls, input_video: VideoInput): - # Save a temporary H264 file so ComfyUI has something to preview - out_dir = os.path.join(folder_paths.get_output_directory(), "previews") - os.makedirs(out_dir, exist_ok=True) - - preview_path = os.path.join(out_dir, "preview_temp.mp4") - input_video.save_to(preview_path, format="mp4", codec="h264") - - # โœ… Return the raw video object (not a tuple) - return io.NodeOutput( - input_video, - ui=ui.PreviewVideo([ - ui.SavedResult("preview_temp.mp4", "previews", io.FolderType.output) - ]) - ) class GroupVideoFramesMXD: CATEGORY = "MXD/Video" @@ -1754,60 +1769,62 @@ class GroupVideoFramesMXD: print(f"[GroupVideoFramesMXD] Split {total} frames into {len(grouped_tensors)} groups of up to {group_size}.") return (grouped_tensors,) -class Wan22FirstLastImageToVideoMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - return io.Schema( - node_id="Wan22FirstLastImageToVideoMXD", - display_name="WAN 2.2 First&Last Image To Video MXD", - category="conditioning/video_models", - inputs=[ - io.Conditioning.Input("positive"), - io.Conditioning.Input("negative"), - io.Vae.Input("vae"), - io.Int.Input("length", default=81, min=1, max=nodes.MAX_RESOLUTION, 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), - ], - outputs=[ - io.Conditioning.Output(display_name="positive"), - io.Conditioning.Output(display_name="negative"), - io.Latent.Output(display_name="latent"), - ], - ) +if HAVE_COMFY_API: + class Wan22FirstLastImageToVideoMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="Wan22FirstLastImageToVideoMXD", + display_name="WAN 2.2 First&Last Image To Video MXD", + category="conditioning/video_models", + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Int.Input("length", default=81, min=1, max=nodes.MAX_RESOLUTION, 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), + ], + 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) -> io.NodeOutput: - spacial_scale = vae.spacial_compression_encode() + @classmethod + def execute(cls, positive, negative, vae, length, batch_size, start_image=None, end_image=None) -> io.NodeOutput: + spacial_scale = vae.spacial_compression_encode() - # Assume incoming images are already pre-sized by upstream nodes. - height, width = start_image.shape[1], start_image.shape[2] if start_image is not None else (vae.latent_channels * spacial_scale, vae.latent_channels * spacial_scale) + # Assume incoming images are already pre-sized by upstream nodes. + height, width = start_image.shape[1], start_image.shape[2] if start_image is not None else (vae.latent_channels * spacial_scale, vae.latent_channels * spacial_scale) - latent = torch.zeros( - [batch_size, vae.latent_channels, ((length - 1) // 4) + 1, height // spacial_scale, width // spacial_scale], - device=comfy.model_management.intermediate_device() - ) + latent = torch.zeros( + [batch_size, vae.latent_channels, ((length - 1) // 4) + 1, height // spacial_scale, width // spacial_scale], + device=comfy.model_management.intermediate_device() + ) - image = torch.ones((length, height, width, 3)) * 0.5 - mask = torch.ones((1, 1, latent.shape[2] * 4, latent.shape[-2], latent.shape[-1])) + image = torch.ones((length, height, width, 3)) * 0.5 + mask = torch.ones((1, 1, latent.shape[2] * 4, latent.shape[-2], latent.shape[-1])) - if start_image is not None: - image[:start_image.shape[0]] = start_image - mask[:, :, :start_image.shape[0] + 3] = 0.0 + if start_image is not None: + image[:start_image.shape[0]] = start_image + mask[:, :, :start_image.shape[0] + 3] = 0.0 - if end_image is not None: - image[-end_image.shape[0]:] = end_image - mask[:, :, -end_image.shape[0]:] = 0.0 + if end_image is not None: + image[-end_image.shape[0]:] = end_image + mask[:, :, -end_image.shape[0]:] = 0.0 - concat_latent_image = vae.encode(image[:, :, :, :3]) - mask = mask.view(1, mask.shape[2] // 4, 4, mask.shape[3], mask.shape[4]).transpose(1, 2) + concat_latent_image = vae.encode(image[:, :, :, :3]) + mask = mask.view(1, mask.shape[2] // 4, 4, mask.shape[3], mask.shape[4]).transpose(1, 2) - positive = node_helpers.conditioning_set_values(positive, {"concat_latent_image": concat_latent_image, "concat_mask": mask}) - negative = node_helpers.conditioning_set_values(negative, {"concat_latent_image": concat_latent_image, "concat_mask": mask}) + positive = node_helpers.conditioning_set_values(positive, {"concat_latent_image": concat_latent_image, "concat_mask": mask}) + negative = node_helpers.conditioning_set_values(negative, {"concat_latent_image": concat_latent_image, "concat_mask": mask}) + + out_latent = {"samples": latent} + return io.NodeOutput(positive, negative, out_latent) - out_latent = {"samples": latent} - return io.NodeOutput(positive, negative, out_latent) # ---------- Node registration ---------- NODE_CLASS_MAPPINGS = { @@ -1819,18 +1836,22 @@ NODE_CLASS_MAPPINGS = { "SaveLatent_I2V_MXD": SaveLatent_I2V_MXD, "LoadLatent_I2V_MXD": LoadLatent_I2V_MXD, "LoadLatents_FromFolder_I2V_MXD": LoadLatents_FromFolder_I2V_MXD, - "Wan22ImageToVideoMXD": Wan22ImageToVideoMXD, "WAN22_I2V_Image_Scaler_MXD": WAN22_I2V_Image_Scaler_MXD, "Frames_Remove_From_Start_MXD": Frames_Remove_From_Start_MXD, - "CombineVideos_MXD": CombineVideos_MXD, - "LoadVideoMXD": LoadVideoMXD, - "SaveVideoMXD": SaveVideoMXD, - "PreviewVideoMXD": PreviewVideoMXD, "GroupVideoFramesMXD": GroupVideoFramesMXD, - "Wan22FirstLastImageToVideoMXD": Wan22FirstLastImageToVideoMXD, "Frames_Select_StartEnd_MXD": Frames_Select_StartEnd_MXD, } +if HAVE_COMFY_API: + NODE_CLASS_MAPPINGS.update({ + "Wan22ImageToVideoMXD": Wan22ImageToVideoMXD, + "CombineVideos_MXD": CombineVideos_MXD, + "LoadVideoMXD": LoadVideoMXD, + "SaveVideoMXD": SaveVideoMXD, + "PreviewVideoMXD": PreviewVideoMXD, + "Wan22FirstLastImageToVideoMXD": Wan22FirstLastImageToVideoMXD, + }) + NODE_DISPLAY_NAME_MAPPINGS = { "SaveLatentMXD": "Save Latent MXD", "LoadLatent_WithParams": "Load Latent MXD", @@ -1840,14 +1861,35 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SaveLatent_I2V_MXD": "Save Latent I2V MXD", "LoadLatent_I2V_MXD": "Load Latent I2V MXD", "LoadLatents_FromFolder_I2V_MXD": "Load Latent Batch I2V MXD", - "Wan22ImageToVideoMXD": "Wan 2.2 Image to Video MXD", "WAN22_I2V_Image_Scaler_MXD": "Image Scaler Wan 2.2 I2V MXD", "Frames_Remove_From_Start_MXD": "Remove Frames From Start MXD", - "CombineVideos_MXD": "Combine Videos MXD", - "LoadVideoMXD": "Load Video MXD", - "SaveVideoMXD": "Save Video MXD", - "PreviewVideoMXD": "Preview Video MXD", "GroupVideoFramesMXD": "Group Video Frames MXD", - "Wan22FirstLastImageToVideoMXD": "Wan 2.2 I2V First & Last Frame MXD", "Frames_Select_StartEnd_MXD": "Select Frames MXD", } + +if HAVE_COMFY_API: + NODE_DISPLAY_NAME_MAPPINGS.update({ + "Wan22ImageToVideoMXD": "Wan 2.2 Image to Video MXD", + "CombineVideos_MXD": "Combine Videos MXD", + "LoadVideoMXD": "Load Video MXD", + "SaveVideoMXD": "Save Video MXD", + "PreviewVideoMXD": "Preview Video MXD", + "Wan22FirstLastImageToVideoMXD": "Wan 2.2 I2V First & Last Frame MXD", + }) + +def _add_mxd_aliases(class_map, display_map): + alias_sources = {} + for key in list(class_map.keys()): + if "MXD" in key.upper(): + continue + alias = f"{key} MXD" + if alias in class_map: + continue + class_map[alias] = class_map[key] + alias_sources[alias] = key + for alias, source in alias_sources.items(): + if alias not in display_map: + display_map[alias] = display_map.get(source, alias) + return alias_sources + +_add_mxd_aliases(NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS)