From f02f7a8c9a0987e99aca9fbbc520585d595aaaf5 Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Sat, 13 Dec 2025 16:15:55 -0800 Subject: [PATCH] Add Z-Image Turbo latent node and update WAN 2.2 scaler Introduces the ZImageTurboEmptyLatentImage node for SD3-compatible latent generation with curated safe resolutions. Refactors SaveLatentMXD and SaveLatent_I2V_MXD to remove preview image logic and streamline latent/conditioning saving. Updates WAN22_I2V_Image_Scaler_MXD to add a 'Safe Auto' mode for robust video-extend workflows, improves bucket selection, and enhances error handling for unsupported resolutions. Also updates display names and increments the package version to 1.3.0. --- maxedoutnodes.py | 89 ++++++++++++ pyproject.toml | 2 +- wan22nodes.py | 364 +++++++++++++++++------------------------------ 3 files changed, 219 insertions(+), 236 deletions(-) diff --git a/maxedoutnodes.py b/maxedoutnodes.py index 4c4bcde..1890cdb 100644 --- a/maxedoutnodes.py +++ b/maxedoutnodes.py @@ -171,6 +171,93 @@ class SdxlEmptyLatentImage: # Typically, the latent space has 4 channels and each spatial dimension is 1/8th of the image. latent = torch.zeros([batch_size, 4, height // 8, width // 8], device=self.device) return ({"samples": latent},) + +######################################################################################################################## +# Z-Image Turbo Empty Latent Image (SD3-compatible) +class ZImageTurboEmptyLatentImage: + DESCRIPTION = """ + - Provides a curated set of SAFE and OPTIMAL resolutions for Z-Image Turbo. + + - All presets stay within Z-Image Turbo's native comfort zone + to avoid composition breakage and latent corruption. + + - Designed to save time and remove guesswork. + """ + TITLE = "Z-Image Turbo Empty Latent Image" + CATEGORY = "MXD/Latent" + + RESOLUTIONS = { + "— Recommended (Best Overall) —": None, + "Square (1:1) 1024x1024": (1024, 1024), + "Landscape (16:9) 1920x1088": (1920, 1088), + "Portrait (2:3) 1024x1536": (1024, 1536), + + "— High Detail —": None, + "Square (1:1) 1280x1280": (1280, 1280), + "Square (1:1) 1536x1536": (1536, 1536), + "Landscape (16:9) 2048x1152": (2048, 1152), + "Portrait (9:16) 1152x2048": (1152, 2048), + + "— Cinematic / Wide —": None, + "Landscape (21:9) 2016x864": (2016, 864), + "Landscape (21:9) 1680x720": (1680, 720), + + "— Fast / Draft —": None, + "Square (1:1) 768x768": (768, 768), + "Landscape (16:9) 1280x720": (1280, 720), + "Portrait (3:4) 832x1216": (832, 1216), + } + + def __init__(self): + self.device = comfy.model_management.intermediate_device() + + @classmethod + def INPUT_TYPES(cls) -> dict: + return { + "required": { + "resolution": ( + list(cls.RESOLUTIONS.keys()), + {"default": "Square (1:1) 1024x1024"} + ), + "vertical": ( + "BOOLEAN", + { + "default": False, + "tooltip": "Swap width and height." + } + ), + "batch_size": ( + "INT", + { + "default": 1, + "min": 1, + "max": 4096, + "tooltip": "Number of latent images in the batch." + } + ) + } + } + + RETURN_TYPES = ("LATENT",) + OUTPUT_TOOLTIPS = ("The empty Z-Image Turbo latent batch.",) + FUNCTION = "generate" + + def generate(self, resolution, vertical, batch_size=1) -> tuple: + size = self.RESOLUTIONS.get(resolution) + if size is None: + raise ValueError(f"'{resolution}' is a header or invalid option.") + + width, height = size + if vertical: + width, height = height, width + + latent = torch.zeros( + [batch_size, 16, height // 8, width // 8], + device=self.device + ) + + return ({"samples": latent},) + ######################################################################################################################## # SDXL Resolution Selector class SdxlResolutionSelector: @@ -973,6 +1060,7 @@ NODE_CLASS_MAPPINGS = { "Crop Image By Mask": CropImageByMask, "Load Image Batch MXD": LoadImageBatchMXD, "LoadImageWithPromptsMXD": LoadImageWithPromptsMXD, + "ZImageTurboEmptyLatentImage": ZImageTurboEmptyLatentImage, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -989,4 +1077,5 @@ NODE_DISPLAY_NAME_MAPPINGS = { "Crop Image By Mask": "Crop Image by Mask MXD", "Load Image Batch MXD": "Load Image Batch MXD", "LoadImageWithPromptsMXD": "Load Image MXD", + "ZImageTurboEmptyLatentImage": "ZImageTurbo Empty Latent Image MXD", } diff --git a/pyproject.toml b/pyproject.toml index d343005..6b8c4d1 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.2.1" +version = "1.3.0" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/wan22nodes.py b/wan22nodes.py index 2cb1110..51e57c2 100644 --- a/wan22nodes.py +++ b/wan22nodes.py @@ -3,8 +3,6 @@ import os, re, glob, json, hashlib from typing import Any, Dict, Tuple, Optional, List, Union import torch -import numpy as np -from PIL import Image from safetensors import safe_open import folder_paths @@ -60,34 +58,25 @@ async def mxd_list_input_videos(request): class SaveLatentMXD: DESCRIPTION = """ - Saves latents to `.latent` files under `input/latents/`. - - - Also decodes & saves preview images for quick inspection in the UI. - - Preserves prompt & extra Comfy metadata inside the file. """ - TITLE = "Save Latent (with Preview)" + TITLE = "Save Latent" CATEGORY = "MXD/Latents" RETURN_TYPES = () # only UI - FUNCTION = "save_and_preview" + FUNCTION = "save_only" OUTPUT_NODE = True @classmethod def INPUT_TYPES(s): return { "required": { - "samples": ("LATENT", {"tooltip": "Latent tensor to save & preview."}), - "vae": ("VAE", {"tooltip": "VAE used to decode preview images."}), + "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"}, } - RETURN_TYPES = () # only UI - FUNCTION = "save_and_preview" - OUTPUT_NODE = True - CATEGORY = "MXD/Latents" - - def save_and_preview(self, samples, vae, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + def save_only(self, samples, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): # ---------- Save Latent ---------- latents_dir = os.path.join(folder_paths.get_input_directory(), "latents") @@ -97,62 +86,91 @@ class SaveLatentMXD: filename_prefix, latents_dir ) - prompt_info = "" - if prompt is not None: - try: - prompt_info = json.dumps(prompt) - except Exception: - pass - - metadata = None + # Metadata + meta = None if not args.disable_metadata: - metadata = {"prompt": prompt_info} + meta = {} + if prompt is not None: + try: meta["prompt"] = json.dumps(prompt) + except: pass if extra_pnginfo is not None: for k, v in extra_pnginfo.items(): - try: - metadata[k] = json.dumps(v) - except Exception: - pass + try: meta[k] = json.dumps(v) + except: pass - file = f"{filename}_{counter:05}_.latent" - file = os.path.join(full_output_folder, file) + file = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent") - output = {"latent_tensor": samples["samples"].contiguous(), - "latent_format_version_0": torch.tensor([])} + payload = { + "latent_tensor": samples["samples"].contiguous(), + "latent_format_version_0": torch.tensor([]), + } - comfy.utils.save_torch_file(output, file, metadata=metadata) + comfy.utils.save_torch_file(payload, file, metadata=meta) - # ---------- Decode + Save preview images ---------- - images = vae.decode(samples["samples"]) - if len(images.shape) == 5: # merge video/batched latents - images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1]) + return {} # no previews, no UI - # Save previews to TEMP so the UI can locate them with type="temp" - temp_dir = folder_paths.get_temp_directory() - # build a preview name using the same helper so subfolder/counter are valid - w, h = images[0].shape[1], images[0].shape[0] - preview_prefix = filename_prefix + "_preview" - full_temp_folder, preview_name, temp_counter, temp_subfolder, _ = folder_paths.get_save_image_path( - preview_prefix, temp_dir, w, h +# ---------- SaveLatent I2V (saves latent + conditioning) ---------- +class SaveLatent_I2V_MXD: + """ + I2V-only saver that persists: + • latent tensor -> .latent + • pos/neg CONDITIONING -> .cond.pt + """ + TITLE = "Save Latent I2V (with Conditioning)" + CATEGORY = "MXD/Latents (I2V)" + OUTPUT_NODE = True + RETURN_TYPES = () + FUNCTION = "save_only" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "samples": ("LATENT", {"tooltip": "High-noise latent to save for later low-noise finishing."}), + "positive": ("CONDITIONING", {"tooltip": "Positive CONDITIONING after WAN image→video."}), + "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"}, + } + + def save_only(self, samples, positive, negative, filename_prefix="I2V", + prompt=None, extra_pnginfo=None): + + # ---- save latent (.latent) ---- + latents_dir = os.path.join(folder_paths.get_input_directory(), "latents") + os.makedirs(latents_dir, exist_ok=True) + + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( + filename_prefix, latents_dir ) - results = [] - for batch_number, image in enumerate(images): - np_img = (255.0 * image.cpu().numpy()) - img = Image.fromarray(np.clip(np_img, 0, 255).astype(np.uint8)) + # Metadata + meta = None + if not args.disable_metadata: + meta = {} + if prompt is not None: + try: meta["prompt"] = json.dumps(prompt) + except: pass + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + try: meta[k] = json.dumps(v) + except: pass - fn_with_batch = preview_name.replace("%batch_num%", str(batch_number)) - preview_file = f"{fn_with_batch}_{temp_counter:05}_.png" - img.save(os.path.join(full_temp_folder, preview_file), compress_level=1) + latent_path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent") - results.append({ - "filename": preview_file, - "subfolder": temp_subfolder, - "type": "temp" # 👈 matches temp_dir so UI can render - }) - temp_counter += 1 + payload = { + "latent_tensor": samples["samples"].contiguous(), + "latent_format_version_0": torch.tensor([]), + } + comfy.utils.save_torch_file(payload, latent_path, metadata=meta) - return {"ui": {"images": results}} + # ---- save conditioning sidecar (.cond.pt) ---- + cond_path = latent_path.replace(".latent", ".cond.pt") + torch.save({"positive": positive, "negative": negative}, cond_path) + + # No preview logic at all + return {} # ---------- Helpers ---------- def _load_latent_file(latent_path: str) -> Tuple[Dict[str, torch.Tensor], Dict[str, Any], List[str]]: @@ -679,7 +697,12 @@ class LoadLatents_FromFolder_WithParams: sample_dict, meta, _ = _load_latent_file(path) t = sample_dict["samples"] - slices = [t[i:i+1].contiguous() for i in range(t.size(0))] if (t.dim() >= 4 and t.size(0) > 1) else [t.unsqueeze(0)] + if isinstance(t, torch.Tensor) and t.dim() >= 4 and t.size(0) > 1: + slices = [t[i:i+1].contiguous() for i in range(t.size(0))] + elif isinstance(t, torch.Tensor) and t.dim() >= 4 and t.size(0) == 1: + slices = [t] + else: + 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 {}) @@ -1098,100 +1121,6 @@ class wan22EmptyHunyuanLatentVideoMXD: device=comfy.model_management.intermediate_device() ) return ({"samples": latent},) - -# ---------- I2V-specific latent save/load (sidecar conditioning; subclassed loader) ---------- -class SaveLatent_I2V_MXD: - """ - I2V-only saver that persists: - • latent tensor -> .latent (safetensors via comfy.utils.save_torch_file) - • pos/neg CONDITIONING -> .cond.pt (torch.save; robust for nested tensors) - • optional preview images to TEMP for UI - """ - TITLE = "Save Latent I2V (with Conditioning)" - CATEGORY = "MXD/Latents (I2V)" - OUTPUT_NODE = True - RETURN_TYPES = () - FUNCTION = "save_and_preview" - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "samples": ("LATENT", {"tooltip": "High-noise latent to save for later low-noise finishing."}), - "positive": ("CONDITIONING", {"tooltip": "Positive CONDITIONING after WAN image→video."}), - "negative": ("CONDITIONING", {"tooltip": "Negative CONDITIONING after WAN image→video."}), - "vae": ("VAE", {"tooltip": "Used to decode preview images for UI convenience."}), - "filename_prefix": ("STRING", {"default": "I2V", "tooltip": "Prefix for saved files"}), - "show_preview": ("BOOLEAN", {"default": False, "tooltip": "Show decoded preview images (slower)"}), - }, - "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, - } - - def save_and_preview(self, samples, positive, negative, vae, filename_prefix="I2V", - show_preview=False, prompt=None, extra_pnginfo=None): - - # ---- save latent (.latent) ---- - latents_dir = os.path.join(folder_paths.get_input_directory(), "latents") - os.makedirs(latents_dir, exist_ok=True) - - full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( - filename_prefix, latents_dir - ) - - meta = None - if not args.disable_metadata: - meta = {} - if prompt is not None: - try: - meta["prompt"] = json.dumps(prompt) - except Exception: - pass - if extra_pnginfo is not None: - for k, v in extra_pnginfo.items(): - try: - meta[k] = json.dumps(v) - except Exception: - pass - - latent_path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent") - - payload = { - "latent_tensor": samples["samples"].contiguous(), - "latent_format_version_0": torch.tensor([]), - } - comfy.utils.save_torch_file(payload, latent_path, metadata=meta) - - # ---- save conditioning sidecar (.cond.pt) ---- - cond_path = latent_path.replace(".latent", ".cond.pt") - torch.save({"positive": positive, "negative": negative}, cond_path) - - # ---- optional preview images ---- - if not show_preview: - return {} # skip VAE decode and preview generation - - images = vae.decode(samples["samples"]) - if len(images.shape) == 5: - images = images.reshape(-1, images.shape[-3], images.shape[-2], images.shape[-1]) - - temp_dir = folder_paths.get_temp_directory() - w, h = images[0].shape[1], images[0].shape[0] - preview_prefix = filename_prefix + "_preview" - full_temp_folder, preview_name, temp_counter, temp_subfolder, _ = folder_paths.get_save_image_path( - preview_prefix, temp_dir, w, h - ) - - results = [] - for b, image in enumerate(images): - np_img = (255.0 * image.cpu().numpy()) - img = Image.fromarray(np.clip(np_img, 0, 255).astype(np.uint8)) - fn_with_batch = preview_name.replace("%batch_num%", str(b)) - preview_file = f"{fn_with_batch}_{temp_counter:05}_.png" - img.save(os.path.join(full_temp_folder, preview_file), compress_level=1) - results.append({"filename": preview_file, "subfolder": temp_subfolder, "type": "temp"}) - temp_counter += 1 - - return {"ui": {"images": results}} - # ---------- WAN 2.2 Image to Video (no scaling; expects pre-sized input) ---------- class Wan22ImageToVideoMXD(io.ComfyNode): @@ -1199,7 +1128,7 @@ class Wan22ImageToVideoMXD(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="Wan22ImageToVideoMXD", - display_name="WAN 2.2 Image to Video", + display_name="WAN 2.2 Image to Video MXD", category="conditioning/video_models", description="WAN 2.2 Image to Video (no scaling, no clip vision)", inputs=[ @@ -1327,46 +1256,35 @@ def _resize_fit_inside(img, out_w, out_h): resized = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1) return resized, tw, th -# ---------- WAN 2.2 Image Scaler (no padding; fit or crop modes; square-aware) ---------- -# Known WAN 2.2 "safe" video buckets (no-pad, multiples of 16) +# ---------- WAN22_I2V_Image_Scaler_MXD ---------- +# Adds a new “Safe Auto” mode for video extend workflows. +# Normal modes (Auto / 480p / 720p) behave exactly as before. +# “Safe Auto” adds passthrough + strict checks to prevent failures on WAN 2.2 extend. + _WAN22_VALID_RES = { - (832, 480), (480, 832), # 480p landscape/portrait - (1280, 720), (720, 1280), # 720p landscape/portrait - (624, 624), # square ~480-tier - (720, 720), # square 720-tier + (832, 480), (480, 832), + (1280, 720), (720, 1280), + (624, 624), (720, 720), } def _wan22_is_valid_dim(w, h): - """Return True if (w, h) is an exact WAN 2.2 bucket we consider safe.""" return (w, h) in _WAN22_VALID_RES class WAN22_I2V_Image_Scaler_MXD: """ MXD Image Scaler for WAN 2.2 (NO PADDING) - - - Modes: Auto / 480p / 720p + - Modes: Auto / 480p / 720p / Safe Auto - Fit (no pad): proportional resize ≤ target; returns resized dims. - Crop (no pad): resize-to-cover then center-crop to exact target. - - Square handling: * Auto & 480p: ~square → 624×624 * 720p: ~square → 720×720 - - - Auto logic: - * Chooses 480p or 720p bucket based on minimal scaling. - * Prefers 480p for small inputs so we don't jump to 720p unnecessarily. - - - Video-safe guardrails: - * If input is already an exact WAN 2.2 bucket: - 832×480, 480×832, - 1280×720, 720×1280, - 624×624, 720×720 - → the image is returned **unchanged** (perfect for video-extend “last frame” use). - - * If input area is far outside 480p/720p range, raises a clear error - explaining that WAN 2.2 does not support arbitrary resolutions and - they should use something near 480p or 720p. + - “Safe Auto”: + * If input is already a valid WAN 2.2 bucket, passthrough. + * If input is far outside 480p–720p range, error early. + * Otherwise, same logic as Auto. + * Perfect for video-extend workflows. """ TITLE = "Image Bucket Scaler MXD (No Pad)" @@ -1379,7 +1297,7 @@ class WAN22_I2V_Image_Scaler_MXD: return { "required": { "image": ("IMAGE",), - "tier": (["Auto", "480p", "720p"], {"default": "Auto"}), + "tier": (["Auto", "480p", "720p", "Safe Auto"], {"default": "Auto"}), "crop_to_fit": ("BOOLEAN", { "default": True, "label_on": "Perfect Fit (Crops Edges)", @@ -1392,7 +1310,6 @@ class WAN22_I2V_Image_Scaler_MXD: # Internal helpers # ----------------------------- def _pick_bucket(self, iw, ih, tier, crop_to_fit): - in_ar = _ar(iw, ih) is_squareish = _is_squareish(iw, ih) is_landscape = iw >= ih @@ -1401,91 +1318,68 @@ class WAN22_I2V_Image_Scaler_MXD: if tier == "720p": return (720, 720) else: - # Auto & 480p share same square bucket return (624, 624) - # --- Explicit 480p tier --- + # --- Explicit tiers --- if tier == "480p": - buckets = [(832, 480)] if is_landscape else [(480, 832)] - return _closest_bucket(iw, ih, buckets, cover=crop_to_fit) - - # --- Explicit 720p tier --- + return _closest_bucket(iw, ih, [(832, 480)] if is_landscape else [(480, 832)], cover=crop_to_fit) if tier == "720p": - buckets = [(1280, 720)] if is_landscape else [(720, 1280)] - return _closest_bucket(iw, ih, buckets, cover=crop_to_fit) + return _closest_bucket(iw, ih, [(1280, 720)] if is_landscape else [(720, 1280)], cover=crop_to_fit) - # --- Auto tier (smart minimal-scaling logic) --- + # --- Auto tier logic --- buckets_480 = [(832, 480)] if is_landscape else [(480, 832)] buckets_720 = [(1280, 720)] if is_landscape else [(720, 1280)] - - iw_ih = iw * ih - area_480 = 832 * 480 - area_720 = 1280 * 720 - + iw_ih = iw * ih + area_480, area_720 = 832 * 480, 1280 * 720 scale_to_480 = abs(iw_ih - area_480) / area_480 scale_to_720 = abs(iw_ih - area_720) / area_720 - # --- Rule 1: small images → stay in 480p bucket (avoid jumping to 720p) --- - # (We still allow upscaling 640x360 → 832x480, etc.) + # prefer minimal scaling if iw <= 832 and ih <= 480: return _closest_bucket(iw, ih, buckets_480, cover=crop_to_fit) - - # --- Rule 2: otherwise pick bucket with smaller scale delta --- - if scale_to_480 <= scale_to_720: - return _closest_bucket(iw, ih, buckets_480, cover=crop_to_fit) - else: - return _closest_bucket(iw, ih, buckets_720, cover=crop_to_fit) + return _closest_bucket(iw, ih, buckets_480 if scale_to_480 <= scale_to_720 else buckets_720, cover=crop_to_fit) # ----------------------------- # Main function # ----------------------------- def scale(self, image, tier="Auto", crop_to_fit=False): - # image: [B, H, W, C] _, ih, iw, _ = image.shape - # 1) VIDEO-EXTEND SAFETY: If the frame is already a known WAN 2.2 bucket, - # just passthrough. This makes "use last frame from WAN video" bulletproof: - # no chance of re-scaling to the wrong tier. - if _wan22_is_valid_dim(iw, ih): - # Already WAN-safe (832x480, 1280x720, 624x624, 720x720, + portrait variants) - return (image,) + # --- Safe Auto logic --- + if tier == "Safe Auto": + # passthrough if already WAN-safe + if _wan22_is_valid_dim(iw, ih): + return (image,) - # 2) HARD BOUNDS: if the resolution is way off from WAN 2.2's expected range, - # error early with a clear message instead of failing after a long video run. - area = iw * ih - area_480 = 832 * 480 # ~399k - area_720 = 1280 * 720 # ~921k - min_area = int(area_480 * 0.5) # ~half of 480p bucket - max_area = int(area_720 * 1.8) # somewhat above 720p bucket + area = iw * ih + area_480, area_720 = 832 * 480, 1280 * 720 + min_area, max_area = int(area_480 * 0.5), int(area_720 * 1.8) - if area < min_area or area > max_area: - size_label = "small" if area < min_area else "large" - raise ValueError( - "[WAN22_I2V_Image_Scaler_MXD] Input resolution " - f"{iw}x{ih} is too {size_label} for WAN 2.2 video buckets.\n" - "WAN 2.2 works best around these ranges:\n" - " • 480p tier ≈ 832×480 (or 480×832)\n" - " • 720p tier ≈ 1280×720 (or 720×1280)\n" - " • Squares: 624×624 or 720×720\n\n" - "Please use a source closer to 480p/720p, or first run it through " - "your WAN 2.2 workflow at one of those tiers. This check exists so " - "the workflow fails early here instead of deep inside WAN 2.2." - ) + if area < min_area or area > max_area: + size_label = "small" if area < min_area else "large" + raise ValueError( + f"[WAN22_I2V_Image_Scaler_MXD] Input resolution {iw}x{ih} is too {size_label} for WAN 2.2 video buckets.\n" + "WAN 2.2 works best around:\n" + " • 480p tier ≈ 832×480 (or 480×832)\n" + " • 720p tier ≈ 1280×720 (or 720×1280)\n" + " • Squares: 624×624 or 720×720\n\n" + "Please use a source closer to 480p/720p, or first process it " + "through your WAN 2.2 workflow. This ensures extend runs without mismatch." + ) + # fallback to Auto scaling + tier = "Auto" - # 3) Normal bucket selection + scaling + # --- Normal path (Auto / 480p / 720p) --- bw, bh = self._pick_bucket(iw, ih, tier, crop_to_fit) is_squareish = _is_squareish(iw, ih) - # Don't crop squares; just fit if is_squareish: crop_to_fit = False if crop_to_fit: - # crop case: bucket dims rounded up to multiples of 16 bw, bh = _safe_hw(_ceil16(bw), _ceil16(bh)) out = _resize_then_center_crop(image, bw, bh) else: - # fit case: bucket dims rounded down to multiples of 16 bw, bh = _safe_hw(_floor16(bw), _floor16(bh)) out, _, _ = _resize_fit_inside(image, bw, bh) @@ -1883,7 +1777,7 @@ class Wan22FirstLastImageToVideoMXD(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="Wan22FirstLastImageToVideoMXD", - display_name="WAN 2.2 First+Last Image → Video MXD", + display_name="WAN 2.2 First&Last Image To Video MXD", category="conditioning/video_models", inputs=[ io.Conditioning.Input("positive"),