From f788892aad86627fa097f196010da4c869c830b0 Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Sat, 25 Oct 2025 13:35:12 -0700 Subject: [PATCH] Refactor WAN 2.2 image-to-video nodes and update version Replaces WanImageToVideoMXD with Wan22ImageToVideoMXD using new schema and execution methods, and adds Wan22FirstLastImageToVideoMXD node. Updates pyproject.toml version to 1.1.2. Adjusts scaler defaults and cleans up redundant code for improved maintainability and node registration. --- pyproject.toml | 2 +- wan22nodes.py | 280 +++++++++++++------------------------------------ 2 files changed, 73 insertions(+), 209 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3b6a09e..c38667d 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.0.2" +version = "1.1.2" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/wan22nodes.py b/wan22nodes.py index 5c33f65..a4fdc66 100644 --- a/wan22nodes.py +++ b/wan22nodes.py @@ -1,5 +1,5 @@ from __future__ import annotations -import os, re, glob, json, hashlib, uuid, math +import os, re, glob, json, hashlib from typing import Any, Dict, Tuple, Optional, List, Union import torch @@ -12,8 +12,7 @@ import comfy.utils import comfy.model_management from comfy.cli_args import args from nodes import KSamplerAdvanced -import node_helpers -import nodes +import node_helpers, nodes # Comfy API from comfy_api.latest import io, ui @@ -21,8 +20,6 @@ from comfy_api.input import VideoInput from comfy_api.input_impl import VideoFromFile, VideoFromComponents from comfy_api.util import VideoComponents, VideoContainer, VideoCodec -import imageio.v3 as iio - # ---------- SaveLatent (Comfy-only; saves into input/latents) ---------- class SaveLatentMXD: DESCRIPTION = """ @@ -1174,54 +1171,44 @@ class LoadLatents_FromFolder_I2V_MXD(LoadLatents_FromFolder_WithParams): # ---------- WAN 2.2 Image to Video (no scaling; expects pre-sized input) ---------- -class WanImageToVideoMXD: - """ - WAN 2.2 Image → Video (MXD) - ⚙️ No scaling — expects pre-sized input. - """ - TITLE = "WAN Image to Video MXD (No Scaling)" - CATEGORY = "conditioning/video_models" - DESCRIPTION = "Encodes a pre-scaled image for WAN 2.2 video conditioning." - - RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") - RETURN_NAMES = ("positive", "negative", "latent") - FUNCTION = "run" +class Wan22ImageToVideoMXD(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="Wan22ImageToVideoMXD", + category="conditioning/video_models", + description="WAN 2.2 Image → Video (no scaling, no 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 INPUT_TYPES(cls): - return { - "required": { - "positive": ("CONDITIONING",), - "negative": ("CONDITIONING",), - "vae": ("VAE",), - "length": ("INT", {"default": 81, "min": 1, "max": 16384, "step": 4}), - "batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}), - }, - "optional": { - "clip_vision_output": ("CLIP_VISION_OUTPUT",), - "start_image": ("IMAGE",), - } - } - - def run(self, positive, negative, vae, length, batch_size, - clip_vision_output=None, start_image=None): - + 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 scaled).") + raise ValueError("start_image must be provided (already pre-sized).") - # dims from the provided (pre-scaled) image frames_in, ih, iw, ch = start_image.shape frames_used = min(frames_in, length) t = ((length - 1) // 4) + 1 - # latent grid sized off the spatial dims and length-derived t latent = torch.zeros( [batch_size, 16, t, ih // 8, iw // 8], device=comfy.model_management.intermediate_device() ) - # ▶ Build a full-length (length, H, W, C) tensor and copy the given frames + # create placeholder image tensor image = torch.ones( (length, ih, iw, ch), device=start_image.device, @@ -1229,10 +1216,10 @@ class WanImageToVideoMXD: ) * 0.5 image[:frames_used] = start_image[:frames_used] - # ▶ Encode the full-length tensor so its latent T matches t + # encode using VAE concat_latent_image = vae.encode(image[:, :, :, :3]) - # ▶ Make mask with T = t, and zero only the used frame-chunks + # 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, @@ -1247,12 +1234,8 @@ class WanImageToVideoMXD: negative, {"concat_latent_image": concat_latent_image, "concat_mask": mask} ) - if clip_vision_output is not None: - positive = node_helpers.conditioning_set_values(positive, {"clip_vision_output": clip_vision_output}) - negative = node_helpers.conditioning_set_values(negative, {"clip_vision_output": clip_vision_output}) - - return (positive, negative, {"samples": 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 @@ -1344,8 +1327,8 @@ class WAN22_I2V_Image_Scaler_MXD: return { "required": { "image": ("IMAGE",), - "tier": (["Auto", "480p", "720p"], {"default": "Auto"}), - "crop_to_fit": ("BOOLEAN", {"default": False, "label_on": "Perfect Fit (Crops Edges)", "label_off": "Closest Fit (No Crop)"}), + "tier": (["Auto", "480p", "720p"], {"default": "480p"}), + "crop_to_fit": ("BOOLEAN", {"default": True, "label_on": "Perfect Fit (Crops Edges)", "label_off": "Closest Fit (No Crop)"}), } } @@ -1549,60 +1532,6 @@ class LoadVideoMXD(io.ComfyNode): latest = cls._find_latest(video_path) return os.path.getmtime(latest) - -class LoadVideoMXD(io.ComfyNode): - @classmethod - def define_schema(cls): - input_dir = folder_paths.get_input_directory() - files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] - files = folder_paths.filter_files_content_types(files, ["video"]) - return io.Schema( - node_id="LoadVideoMXD", - display_name="Load Video MXD", - category="image/video", - description="Always reloads the newest video with the same base name each time you run.", - inputs=[ - io.Combo.Input( - "file", - options=sorted(files), - upload=io.UploadType.video, - tooltip="Pick or upload the base video. The newest version will be auto-loaded on next run." - ), - ], - outputs=[ - io.Video.Output("video"), - io.String.Output("video_path"), - ], - ) - - @classmethod - def _find_latest(cls, base_path: str) -> str: - base_dir, base_filename = os.path.split(base_path) - base_name, _ = os.path.splitext(base_filename) - related = [ - os.path.join(base_dir, f) - for f in os.listdir(base_dir) - if f.startswith(base_name) and os.path.isfile(os.path.join(base_dir, f)) - ] - if not related: - return base_path - return max(related, key=os.path.getmtime) - - @classmethod - def execute(cls, file): - video_path = folder_paths.get_annotated_filepath(file) - latest = cls._find_latest(video_path) - if latest != video_path: - print(f"[LoadVideoMXD] Reloading latest: {os.path.basename(latest)}") - return io.NodeOutput(VideoFromFile(latest), latest) - - @classmethod - def fingerprint_inputs(cls, file): - video_path = folder_paths.get_annotated_filepath(file) - latest = cls._find_latest(video_path) - return os.path.getmtime(latest) - - class SaveVideoMXD(io.ComfyNode): @classmethod def define_schema(cls): @@ -1719,126 +1648,59 @@ class GroupVideoFramesMXD: print(f"[GroupVideoFramesMXD] Split {total} frames into {len(grouped_tensors)} groups of up to {group_size}.") return (grouped_tensors,) -import os -import torch -from comfy_api.latest import io, ui -from comfy_api.util import VideoComponents, VideoContainer, VideoCodec -from comfy_api.input_impl import VideoFromFile, VideoFromComponents -import folder_paths -from comfy.cli_args import args - - -class SaveAndMergeWhenComplete_MXD(io.ComfyNode): - """ - Saves batched video parts and merges them automatically once all expected parts are present. - """ - +class Wan22FirstLastImageToVideoMXD(io.ComfyNode): @classmethod def define_schema(cls): return io.Schema( - node_id="SaveAndMergeWhenComplete_MXD", - display_name="Save & Merge When Complete (MXD)", - category="MXD/video", - description="Saves each incoming video part, and merges all parts once the expected count is reached.", + node_id="Wan22FirstLastImageToVideoMXD", + category="conditioning/video_models", inputs=[ - io.Video.Input("video", tooltip="Video to save (one per batch item)."), - io.String.Input( - "folder_name", - default="temp_batch_merge", - tooltip="Subfolder under output/video/ to save temporary parts." - ), - io.Int.Input( - "expected_parts", - default=2, - min=1, - max=9999, - tooltip="Total number of parts to wait for before merging." - ), - io.Combo.Input( - "format", - options=VideoContainer.as_input(), - default="auto", - tooltip="Container format for the saved videos." - ), - io.Combo.Input( - "codec", - options=VideoCodec.as_input(), - default="auto", - tooltip="Codec to use for the video." - ), + 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.String.Output("final_video_path", tooltip="Full path of the merged video (only once complete)."), + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent"), ], - hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], - is_output_node=False, ) @classmethod - def execute(cls, video, folder_name, expected_parts, format, codec) -> io.NodeOutput: - output_dir = os.path.join(folder_paths.get_output_directory(), "video", folder_name) - os.makedirs(output_dir, exist_ok=True) + def execute(cls, positive, negative, vae, length, batch_size, start_image=None, end_image=None) -> io.NodeOutput: + spacial_scale = vae.spacial_compression_encode() - # --- Save incoming video part --- - part_index = len([f for f in os.listdir(output_dir) if f.endswith(".mp4")]) - part_path = os.path.join(output_dir, f"part_{part_index+1:03d}.mp4") + # 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) - # --- Metadata (same as SaveVideo) --- - 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 - - video.save_to(part_path, format=format, codec=codec, metadata=saved_metadata) - print(f"[SaveAndMergeWhenComplete_MXD] 💾 Saved part {part_index+1}/{expected_parts} → {part_path}") - - # --- Check how many parts exist --- - part_files = sorted([ - os.path.join(output_dir, f) - for f in os.listdir(output_dir) - if f.lower().endswith(".mp4") - ]) - - if len(part_files) < expected_parts: - print(f"[SaveAndMergeWhenComplete_MXD] Waiting for all parts ({len(part_files)}/{expected_parts})...") - return io.NodeOutput(final_video_path="") # Not ready yet - - # ✅ All parts present → merge them - print(f"[SaveAndMergeWhenComplete_MXD] All {expected_parts} parts found. Starting merge...") - - comp_ref = VideoFromFile(part_files[0]).get_components() - all_frames = [] - all_audio = [] - frame_rate = comp_ref.frame_rate - - for path in part_files: - vid = VideoFromFile(path) - comp = vid.get_components() - frames = torch.stack(comp.images) if isinstance(comp.images, list) else comp.images - all_frames.append(frames) - if comp.audio is not None: - all_audio.append(comp.audio) - - merged_frames = torch.cat(all_frames, dim=0) - merged_audio = torch.cat(all_audio, dim=1) if all_audio else None - - combined_video = VideoFromComponents( - VideoComponents(images=merged_frames, audio=merged_audio, frame_rate=frame_rate) + latent = torch.zeros( + [batch_size, vae.latent_channels, ((length - 1) // 4) + 1, height // spacial_scale, width // spacial_scale], + device=comfy.model_management.intermediate_device() ) - final_path = os.path.join(output_dir, "merged_final.mp4") - combined_video.save_to(final_path, format=format, codec=codec, metadata=saved_metadata) + image = torch.ones((length, height, width, 3)) * 0.5 + mask = torch.ones((1, 1, latent.shape[2] * 4, latent.shape[-2], latent.shape[-1])) - print(f"[SaveAndMergeWhenComplete_MXD] ✅ Merged {expected_parts} parts → {final_path}") + if start_image is not None: + image[:start_image.shape[0]] = start_image + mask[:, :, :start_image.shape[0] + 3] = 0.0 - return io.NodeOutput(final_path) + 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) + 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) # ---------- Node registration ---------- NODE_CLASS_MAPPINGS = { @@ -1850,7 +1712,7 @@ NODE_CLASS_MAPPINGS = { "SaveLatent_I2V_MXD": SaveLatent_I2V_MXD, "LoadLatent_I2V_MXD": LoadLatent_I2V_MXD, "LoadLatents_FromFolder_I2V_MXD": LoadLatents_FromFolder_I2V_MXD, - "WanImageToVideoMXD": WanImageToVideoMXD, + "Wan22ImageToVideoMXD": Wan22ImageToVideoMXD, "WAN22_I2V_Image_Scaler_MXD": WAN22_I2V_Image_Scaler_MXD, "Frames_Select_End_MXD": Frames_Select_End_MXD, "Frames_Remove_From_Start_MXD": Frames_Remove_From_Start_MXD, @@ -1858,6 +1720,7 @@ NODE_CLASS_MAPPINGS = { "LoadVideoMXD": LoadVideoMXD, "SaveVideoMXD": SaveVideoMXD, "GroupVideoFramesMXD": GroupVideoFramesMXD, + "Wan22FirstLastImageToVideoMXD": Wan22FirstLastImageToVideoMXD, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -1869,12 +1732,13 @@ 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", - "WanImageToVideoMXD": "WAN Image to Video MXD", - "WAN22_I2V_Image_Scaler_MXD": "WAN 2.2 I2V Image Scaler MXD", + "Wan22ImageToVideoMXD": "Wan 2.2 Image to Video MXD", + "WAN22_I2V_Image_Scaler_MXD": "Wan 2.2 I2V Image Scaler MXD", "Frames_Select_End_MXD": "Frames Select End MXD", "Frames_Remove_From_Start_MXD": "Frames Remove From Start MXD", "CombineVideos_MXD": "Combine Videos MXD", "LoadVideoMXD": "Load Video MXD", "SaveVideoMXD": "Save Video MXD", "GroupVideoFramesMXD": "Group Video Frames MXD", + "Wan22FirstLastImageToVideoMXD": "Wan 2.2 First/Last Image to Video MXD", }