From f90da116032d26167446346bc4da1eaaec2fba42 Mon Sep 17 00:00:00 2001 From: Maxed-Out-99 Date: Fri, 10 Oct 2025 13:53:48 -0700 Subject: [PATCH] Update WAN 2.2 image-to-video bucketing and scaling Refactored image-to-video conditioning to support canonical WAN 2.2 buckets for 480p and 720p, added auto bucket selection, and introduced an optional crop-to-fit mode. Resolution presets and bucketing logic were updated for accuracy, and image resizing now supports both fit-inside and center-crop strategies. --- wan22nodes.py | 202 ++++++++++++++++++++++++-------------------------- 1 file changed, 97 insertions(+), 105 deletions(-) diff --git a/wan22nodes.py b/wan22nodes.py index 05d523c..959721c 100644 --- a/wan22nodes.py +++ b/wan22nodes.py @@ -14,6 +14,7 @@ import node_helpers from comfy_api.latest import ComfyExtension, io + # ---------- SaveLatent (Comfy-only; saves into input/latents) ---------- class SaveLatentMXD: DESCRIPTION = """ @@ -739,15 +740,10 @@ class Wan2_2EmptyLatentImageMXD: RESOLUTIONS = { "— 720p —": None, "Widescreen (16:9) 1280×720": (1280, 720), - "Square (1:1) 960×960": (960, 960), - "Standard (4:3) 960×720": (960, 720), - "Landscape (3:2) 1080×720": (1080, 720), "— 480p —": None, "Widescreen (16:9) 832×480": (832, 480), - "Square (1:1) 640×640": (640, 640), - "Standard (4:3) 640×480": (640, 480), - "Landscape (3:2) 720×480": (720, 480), + "Square (1:1) 624×624": (624, 624), } RETURN_TYPES = ("LATENT",) @@ -800,25 +796,21 @@ class Wan2_2EmptyLatentImageMXD: class wan22EmptyHunyuanLatentVideoMXD: """ Exactly like core EmptyHunyuanLatentVideo, but width/height are replaced - with resolution presets and a vertical toggle. + with valid WAN 2.2 resolution presets and a vertical toggle. """ RETURN_TYPES = ("LATENT",) FUNCTION = "generate" CATEGORY = "latent/video" + # ✅ Cleaned, WAN 2.2–accurate presets RESOLUTIONS = { "— 720p —": None, "Widescreen (16:9) 1280×720": (1280, 720), - "Square (1:1) 960×960": (960, 960), - "Standard (4:3) 960×720": (960, 720), - "Landscape (3:2) 1088×720": (1088, 720), "— 480p —": None, "Widescreen (16:9) 832×480": (832, 480), - "Square (1:1) 640×640": (640, 640), - "Standard (4:3) 640×480": (640, 480), - "Landscape (3:2) 720×480": (720, 480), + "Square (1:1) 624×624": (624, 624), } @classmethod @@ -854,14 +846,13 @@ class wan22EmptyHunyuanLatentVideoMXD: w, h = h, w # identical to core behavior: - # channels=16, time=((length-1)//4)+1, spatial downsample /8 t = ((length - 1) // 4) + 1 latent = torch.zeros( [batch_size, 16, t, h // 8, w // 8], device=comfy.model_management.intermediate_device() ) return ({"samples": latent},) - + # ---------- I2V-specific latent save/load (sidecar conditioning; subclassed loader) ---------- class SaveLatent_I2V_MXD: @@ -1077,21 +1068,67 @@ class LoadLatent_I2V_MXD(LoadLatent_WithParams): prefix, ) -# --- Bucketing sets (no-crop/no-pad) --- -BUCKETS_480P = [(832,480), (640,640), (640,480), (720,480)] -ALL_BUCKETS = BUCKETS_480P +# ---- Canonical WAN 2.2 buckets ---- +BUCKETS_480 = [(832,480), (480,832), (624,624)] # 16:9, 9:16, 1:1 +BUCKETS_720 = [(1280,720), (720,1280)] # 16:9, 9:16 + +def _round16(x: float) -> int: + x = int(round(x / 16.0) * 16) + return max(16, x) + +def _safe_hw(w: int, h: int): + w = max(16, min(w, nodes.MAX_RESOLUTION)) + h = max(16, min(h, nodes.MAX_RESOLUTION)) + return w, h + +def _ar(w, h): return w / max(1, h) + +def _closest_bucket(img_w, img_h, bucket_list, cover=False): + """Pick the best (bw,bh) from bucket_list for this image.""" + if not bucket_list: + return None + in_ar = _ar(img_w, img_h) + best = None + best_key = (float("inf"), 0) + for bw, bh in bucket_list: + s = max(bw / img_w, bh / img_h) if cover else min(bw / img_w, bh / img_h) + ar_diff = abs(_ar(bw, bh) - in_ar) + key = (abs(1.0 - s), ar_diff) + if key < best_key: + best_key, best = key, (bw, bh) + return best + +def _resize_then_center_crop(img, out_w, out_h): + """Resize to cover then center-crop.""" + t, ih, iw, c = img.shape + s = max(out_w / iw, out_h / ih) + tw, th = _round16(int(iw * s)), _round16(int(ih * s)) + tmp = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1) + y0, x0 = max(0, (th - out_h)//2), max(0, (tw - out_w)//2) + return tmp[:, y0:y0+out_h, x0:x0+out_w, :] + +def _resize_fit_inside(img, out_w, out_h): + """Resize to fit inside target while keeping AR.""" + t, ih, iw, c = img.shape + s = min(out_w / iw, out_h / ih) + tw, th = _round16(int(iw * s)), _round16(int(ih * s)) + tw, th = _safe_hw(tw, th) + resized = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1) + return resized, tw, th + class WanImageToVideoMXD: """ WAN 2.2 Image → Video (MXD) - - Auto-buckets start_image by closest aspect ratio. - - Fixed 480p-only scaling (no crop, no pad). - - Proportional resize; /16 rounding; latent sized accordingly. + + - Auto chooses 480p or 720p based on AR and input size. + - Optional Crop-to-Fit (off by default). + - Automatically scales image down or up to closest bucket. """ TITLE = "WAN Image to Video MXD" CATEGORY = "conditioning/video_models" - DESCRIPTION = "Image-to-video conditioning with simple 480p bucketed scaling (no crop, no pad)." + DESCRIPTION = "Image-to-video conditioning with Auto/480p/720p scaling and optional crop-to-fit." RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT") RETURN_NAMES = ("positive", "negative", "latent") @@ -1106,6 +1143,8 @@ class WanImageToVideoMXD: "vae": ("VAE",), "length": ("INT", {"default": 81, "min": 1, "max": 16384, "step": 4}), "batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}), + "tier": (["Auto", "480p", "720p"], {"default": "Auto"}), + "crop_to_fit": ("BOOLEAN", {"default": False, "label_on": "Crop to Fit", "label_off": "Fit Inside"}), }, "optional": { "clip_vision_output": ("CLIP_VISION_OUTPUT",), @@ -1113,105 +1152,58 @@ class WanImageToVideoMXD: } } - # ---------- helpers ---------- + # -------- internals -------- @staticmethod - def _buckets_for_mode(): - return BUCKETS_480P + def _pick_bucket(img_w, img_h, tier, crop_to_fit): + in_ar = _ar(img_w, img_h) - @staticmethod - def _round16(x: float) -> int: - x = int(round(x / 16.0) * 16) - return max(16, x) + if tier == "480p": + return _closest_bucket(img_w, img_h, BUCKETS_480, crop_to_fit) + if tier == "720p": + return _closest_bucket(img_w, img_h, BUCKETS_720, crop_to_fit) - @classmethod - def _best_bucket_by_min_scale(cls, img_w: int, img_h: int): - """Fit-inside strategy for I2V: - - s = min(bw/img_w, bh/img_h) (uniform scale to fit inside preset) - - clamp s <= 1.0 (no upscaling) - - choose bucket that MAXIMIZES s (least shrink) - - tie-breaker: smaller AR difference to the input - Returns (bw, bh, s). - """ - buckets = cls._buckets_for_mode() - if not buckets: - return None + # Auto: choose smartly + if 0.95 <= in_ar <= 1.05: # square-ish → 480p 624x624 + return (624, 624) + # prefer 720p for wider or portrait inputs + return _closest_bucket(img_w, img_h, BUCKETS_720 if max(img_w, img_h) > 720 else BUCKETS_480, crop_to_fit) - ar_in = img_w / max(1, img_h) + # -------- main -------- + def run(self, positive, negative, vae, length, batch_size, + tier="Auto", crop_to_fit=False, clip_vision_output=None, start_image=None): - best = None - best_key = (-1.0, float("inf")) # maximize s, then minimize ar_diff - - for bw, bh in buckets: - s_raw = min(bw / max(1, img_w), bh / max(1, img_h)) - s = min(1.0, s_raw) # never upscale for I2V - ar_diff = abs((bw / bh) - ar_in) - key = (s, -ar_diff) # larger s preferred; for ties, smaller ar_diff - - if key > best_key: - best_key = key - best = (bw, bh, s) - - return best # (bw, bh, s) - - @classmethod - def _bucketed_size_no_pad(cls, img_w: int, img_h: int): - """Compute target (w,h) by fit-inside selection, then /16 & clamp.""" - best = cls._best_bucket_by_min_scale(img_w, img_h) - if best is None: - # Fallback: just round original to multiples of 16 (rare) - tw, th = cls._round16(img_w), cls._round16(img_h) - if tw > nodes.MAX_RESOLUTION or th > nodes.MAX_RESOLUTION: - s2 = min(nodes.MAX_RESOLUTION / tw, nodes.MAX_RESOLUTION / th) - tw, th = cls._round16(tw * s2), cls._round16(th * s2) - return tw, th - - _, _, s = best - tw = cls._round16(img_w * s) - th = cls._round16(img_h * s) - - # Safety clamp preserving AR (should rarely trigger) - if tw > nodes.MAX_RESOLUTION or th > nodes.MAX_RESOLUTION: - s2 = min(nodes.MAX_RESOLUTION / tw, nodes.MAX_RESOLUTION / th) - tw = cls._round16(tw * s2) - th = cls._round16(th * s2) - return tw, th - - # -------------------------------- - - def run(self, positive, negative, vae, length, batch_size, clip_vision_output=None, start_image=None): - # Decide target size - if start_image is not None: - # start_image: (T, H, W, C) - _, ih, iw, _ = start_image.shape - width, height = self._bucketed_size_no_pad(iw, ih) + # Default when no start image + if start_image is None: + final_w, final_h = 832, 480 else: - # Default when start_image is absent - width, height = 832, 480 + _, ih, iw, _ = start_image.shape + bw, bh = self._pick_bucket(iw, ih, tier, crop_to_fit) + final_w, final_h = _safe_hw(_round16(bw), _round16(bh)) + # Latent setup + t = ((length - 1) // 4) + 1 latent = torch.zeros( - [batch_size, 16, ((length - 1) // 4) + 1, height // 8, width // 8], + [batch_size, 16, t, final_h // 8, final_w // 8], device=comfy.model_management.intermediate_device() ) - # Encode start image (optional) + # Encode start image if start_image is not None: - # No cropping: sizes already preserve AR; "center" is safe across builds - start_image = comfy.utils.common_upscale( - start_image[:length].movedim(-1, 1), - width, height, - "bilinear", "center" - ).movedim(1, -1) + if crop_to_fit: + framed = _resize_then_center_crop(start_image[:length], final_w, final_h) + else: + resized, rw, rh = _resize_fit_inside(start_image[:length], final_w, final_h) + framed = torch.ones((resized.shape[0], final_h, final_w, resized.shape[-1]), + device=resized.device, dtype=resized.dtype) * 0.5 + y0, x0 = (final_h - rh)//2, (final_w - rw)//2 + framed[:, y0:y0+rh, x0:x0+rw, :] = resized - image = torch.ones((length, height, width, start_image.shape[-1]), - device=start_image.device, dtype=start_image.dtype) * 0.5 - image[:start_image.shape[0]] = start_image - - concat_latent_image = vae.encode(image[:, :, :, :3]) + concat_latent_image = vae.encode(framed[:, :, :, :3]) mask = torch.ones( (1, 1, latent.shape[2], concat_latent_image.shape[-2], concat_latent_image.shape[-1]), - device=start_image.device, dtype=start_image.dtype + device=framed.device, dtype=framed.dtype ) - mask[:, :, :((start_image.shape[0] - 1) // 4) + 1] = 0.0 + mask[:, :, :((framed.shape[0] - 1) // 4) + 1] = 0.0 positive = node_helpers.conditioning_set_values(positive, { "concat_latent_image": concat_latent_image, "concat_mask": mask