Add I2V batch loader and image scaler nodes
Introduces LoadLatents_FromFolder_I2V_MXD for batch loading latents with conditioning, and WAN22_I2V_Image_Scaler_MXD for bucket-based image scaling without padding. Refactors latent path handling for consistency, improves image-to-video node logic, and updates node registration and display mappings accordingly.
This commit is contained in:
+320
-151
@@ -11,7 +11,8 @@ from nodes import KSamplerAdvanced
|
||||
import nodes
|
||||
import comfy.model_management
|
||||
import node_helpers
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
from comfy_api.latest import ComfyExtension, io, ui
|
||||
import imageio.v3 as iio
|
||||
|
||||
|
||||
|
||||
@@ -245,7 +246,7 @@ class LoadLatent_WithParams:
|
||||
|
||||
files = glob.glob(os.path.join(latents_root, "**", "*.latent"), recursive=True)
|
||||
files.sort()
|
||||
options = [os.path.relpath(f, folder_paths.get_input_directory()).replace(os.sep, "/") for f in files]
|
||||
options = [os.path.relpath(f, latents_root).replace(os.sep, "/") for f in files]
|
||||
|
||||
# live enums from KSamplerAdvanced so values wire cleanly
|
||||
from nodes import KSamplerAdvanced
|
||||
@@ -400,7 +401,7 @@ class LoadLatent_WithParams:
|
||||
return 5.0
|
||||
|
||||
def load(self, latent):
|
||||
latent_path = folder_paths.get_annotated_filepath(latent)
|
||||
latent_path = folder_paths.get_annotated_filepath(f"latents/{latent}")
|
||||
sample_dict, meta, _ = _load_latent_file(latent_path)
|
||||
t = sample_dict["samples"]
|
||||
|
||||
@@ -450,9 +451,9 @@ class LoadLatent_WithParams:
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, latent):
|
||||
p = folder_paths.get_annotated_filepath(latent)
|
||||
p = folder_paths.get_annotated_filepath(f"latents/{latent}")
|
||||
m = hashlib.sha256()
|
||||
with open(p, 'rb') as f:
|
||||
with open(p, "rb") as f:
|
||||
m.update(f.read())
|
||||
return m.digest().hex()
|
||||
|
||||
@@ -462,6 +463,7 @@ class LoadLatent_WithParams:
|
||||
return f"Invalid latent file: {latent}"
|
||||
return True
|
||||
|
||||
|
||||
# ---------- Load multiple latents from a folder (WITH Comfy params, list outputs, video-safe) ----------
|
||||
class LoadLatents_FromFolder_WithParams:
|
||||
DESCRIPTION = """
|
||||
@@ -854,13 +856,12 @@ class wan22EmptyHunyuanLatentVideoMXD:
|
||||
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)
|
||||
• preview images to TEMP for UI
|
||||
• optional preview images to TEMP for UI
|
||||
"""
|
||||
TITLE = "Save Latent I2V (with Conditioning)"
|
||||
CATEGORY = "MXD/Latents (I2V)"
|
||||
@@ -877,12 +878,13 @@ class SaveLatent_I2V_MXD:
|
||||
"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",
|
||||
prompt=None, extra_pnginfo=None):
|
||||
show_preview=False, prompt=None, extra_pnginfo=None):
|
||||
|
||||
# ---- save latent (.latent) ----
|
||||
latents_dir = os.path.join(folder_paths.get_input_directory(), "latents")
|
||||
@@ -919,7 +921,10 @@ class SaveLatent_I2V_MXD:
|
||||
cond_path = latent_path.replace(".latent", ".cond.pt")
|
||||
torch.save({"positive": positive, "negative": negative}, cond_path)
|
||||
|
||||
# ---- previews to TEMP for UI ----
|
||||
# ---- 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])
|
||||
@@ -942,8 +947,6 @@ class SaveLatent_I2V_MXD:
|
||||
temp_counter += 1
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
|
||||
class LoadLatent_I2V_MXD(LoadLatent_WithParams):
|
||||
"""
|
||||
Same outputs as LoadLatent_WithParams plus two CONDITIONING outputs at the end.
|
||||
@@ -980,30 +983,21 @@ class LoadLatent_I2V_MXD(LoadLatent_WithParams):
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
# mirror base: build file list
|
||||
latents_root = os.path.join(folder_paths.get_input_directory(), "latents")
|
||||
os.makedirs(latents_root, exist_ok=True)
|
||||
files = glob.glob(os.path.join(latents_root, "**", "*.latent"), recursive=True)
|
||||
files.sort()
|
||||
options = [os.path.relpath(f, folder_paths.get_input_directory()).replace(os.sep, "/") for f in files]
|
||||
# Clean dropdown display (no "latents/" prefix)
|
||||
options = [os.path.relpath(f, latents_root).replace(os.sep, "/") for f in files]
|
||||
|
||||
# pull live enums from KSamplerAdvanced and attach them to THIS CLASS
|
||||
ks_inputs = KSamplerAdvanced.INPUT_TYPES().get("required", {})
|
||||
samplers_enum = ks_inputs.get("sampler_name", ("STRING",))[0]
|
||||
schedulers_enum = ks_inputs.get("scheduler", ("STRING",))[0]
|
||||
|
||||
# rebuild RETURN_TYPES on THIS CLASS so ports wire correctly
|
||||
s.RETURN_TYPES = (
|
||||
"FLOAT", # shift
|
||||
"CONDITIONING", # positive conditioning
|
||||
"CONDITIONING", # negative conditioning
|
||||
"LATENT",
|
||||
"INT",
|
||||
"FLOAT",
|
||||
samplers_enum,
|
||||
schedulers_enum,
|
||||
"INT",
|
||||
"STRING", # filename_prefix
|
||||
"FLOAT", "CONDITIONING", "CONDITIONING", "LATENT",
|
||||
"INT", "FLOAT", samplers_enum, schedulers_enum,
|
||||
"INT", "STRING",
|
||||
)
|
||||
s._SAMPLERS_ENUM = samplers_enum
|
||||
s._SCHEDULERS_ENUM = schedulers_enum
|
||||
@@ -1012,7 +1006,8 @@ class LoadLatent_I2V_MXD(LoadLatent_WithParams):
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, latent):
|
||||
p = folder_paths.get_annotated_filepath(latent)
|
||||
# Fix path lookup (add "latents/" prefix back)
|
||||
p = folder_paths.get_annotated_filepath(f"latents/{latent}")
|
||||
m = hashlib.sha256()
|
||||
with open(p, "rb") as f:
|
||||
m.update(f.read())
|
||||
@@ -1024,15 +1019,17 @@ class LoadLatent_I2V_MXD(LoadLatent_WithParams):
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, latent):
|
||||
return LoadLatent_WithParams.VALIDATE_INPUTS(latent)
|
||||
# Pass prefixed path to base validator
|
||||
return LoadLatent_WithParams.VALIDATE_INPUTS(f"latents/{latent}")
|
||||
|
||||
def load(self, latent):
|
||||
# Use base loader to get shift + metadata
|
||||
# Use base loader (add prefix so it finds the file)
|
||||
base_tuple = super().load(latent)
|
||||
|
||||
# sidecar conditioning
|
||||
latent_path = folder_paths.get_annotated_filepath(latent)
|
||||
# Load .cond.pt (conditioning data)
|
||||
latent_path = folder_paths.get_annotated_filepath(f"latents/{latent}")
|
||||
cond_path = latent_path.replace(".latent", ".cond.pt")
|
||||
|
||||
positive_conditioning, negative_conditioning = [], []
|
||||
if os.path.exists(cond_path):
|
||||
try:
|
||||
@@ -1043,96 +1040,139 @@ class LoadLatent_I2V_MXD(LoadLatent_WithParams):
|
||||
positive_conditioning, negative_conditioning = [], []
|
||||
|
||||
(
|
||||
shift,
|
||||
_pos_text,
|
||||
_neg_text,
|
||||
samples,
|
||||
steps,
|
||||
cfg,
|
||||
sampler_name,
|
||||
scheduler,
|
||||
end_at_step,
|
||||
prefix,
|
||||
shift, _pos_text, _neg_text, samples,
|
||||
steps, cfg, sampler_name, scheduler,
|
||||
end_at_step, prefix,
|
||||
) = base_tuple
|
||||
|
||||
return (
|
||||
shift,
|
||||
positive_conditioning,
|
||||
negative_conditioning,
|
||||
samples,
|
||||
steps,
|
||||
cfg,
|
||||
sampler_name,
|
||||
scheduler,
|
||||
end_at_step,
|
||||
prefix,
|
||||
shift, positive_conditioning, negative_conditioning,
|
||||
samples, steps, cfg, sampler_name, scheduler,
|
||||
end_at_step, prefix,
|
||||
)
|
||||
|
||||
# ---- 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
|
||||
class LoadLatents_FromFolder_I2V_MXD(LoadLatents_FromFolder_WithParams):
|
||||
"""
|
||||
Same as LoadLatents_FromFolder_WithParams, but includes CONDITIONING outputs
|
||||
(positive/negative tensors) loaded from paired `.cond.pt` sidecar files.
|
||||
"""
|
||||
TITLE = "Load Latents (Folder, I2V + Conditioning)"
|
||||
CATEGORY = "MXD/Latents (I2V)"
|
||||
FUNCTION = "load_batch_i2v"
|
||||
|
||||
def _round16(x: float) -> int:
|
||||
x = int(round(x / 16.0) * 16)
|
||||
return max(16, x)
|
||||
RETURN_TYPES = (
|
||||
"FLOAT", # shift
|
||||
"CONDITIONING", # positive conditioning
|
||||
"CONDITIONING", # negative conditioning
|
||||
"LATENT",
|
||||
"INT",
|
||||
"FLOAT",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"INT",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"shift",
|
||||
"positive",
|
||||
"negative",
|
||||
"samples",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler_name",
|
||||
"scheduler",
|
||||
"end_at_step",
|
||||
"filename_prefix",
|
||||
)
|
||||
|
||||
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
|
||||
OUTPUT_IS_LIST = (True,) * 10 # same length for all outputs
|
||||
|
||||
def _ar(w, h): return w / max(1, h)
|
||||
def load_batch_i2v(self, subfolder):
|
||||
latents_root = os.path.join(folder_paths.get_input_directory(), "latents")
|
||||
base = os.path.join(latents_root, subfolder) if subfolder else latents_root
|
||||
files = glob.glob(os.path.join(base, "**", "*.latent"), recursive=True)
|
||||
files.sort()
|
||||
if not files:
|
||||
raise RuntimeError(f"[LoadLatents_FromFolder_I2V_MXD] No .latent files found in '{base}'.")
|
||||
|
||||
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
|
||||
shifts, samples_list = [], []
|
||||
positives, negatives = [], []
|
||||
steps_list, cfgs, samplers, schedulers, end_steps = [], [], [], [], []
|
||||
filename_prefixes = []
|
||||
|
||||
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, :]
|
||||
for path in files:
|
||||
sample_dict, meta, _ = _load_latent_file(path)
|
||||
t = sample_dict["samples"]
|
||||
|
||||
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
|
||||
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))]
|
||||
else:
|
||||
slices = [t if (isinstance(t, torch.Tensor) and t.dim() >= 4 and t.size(0) == 1)
|
||||
else 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 {})
|
||||
|
||||
sampler_name = self._coerce_enum(sampler_name, getattr(self.__class__, "_SAMPLERS_ENUM", ()))
|
||||
scheduler = self._coerce_enum(scheduler, getattr(self.__class__, "_SCHEDULERS_ENUM", ()))
|
||||
shift_val = self._extract_sd3_shift(meta, prompt_json)
|
||||
|
||||
# Load sidecar conditionings
|
||||
cond_path = path.replace(".latent", ".cond.pt")
|
||||
positive_conditioning, negative_conditioning = [], []
|
||||
if os.path.exists(cond_path):
|
||||
try:
|
||||
d = torch.load(cond_path, map_location="cpu")
|
||||
positive_conditioning = d.get("positive", [])
|
||||
negative_conditioning = d.get("negative", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
folder_part = subfolder if subfolder else ""
|
||||
clean_stem = self._strip_counter(os.path.basename(path))
|
||||
prefix = os.path.join(folder_part, clean_stem) if folder_part else clean_stem
|
||||
|
||||
for sl in slices:
|
||||
shifts.append(float(shift_val))
|
||||
positives.append(positive_conditioning)
|
||||
negatives.append(negative_conditioning)
|
||||
samples_list.append({"samples": sl})
|
||||
steps_list.append(int(n_steps))
|
||||
cfgs.append(float(cfg))
|
||||
samplers.append(sampler_name)
|
||||
schedulers.append(scheduler)
|
||||
end_steps.append(int(end_at_step))
|
||||
filename_prefixes.append(prefix)
|
||||
|
||||
return (
|
||||
shifts,
|
||||
positives,
|
||||
negatives,
|
||||
samples_list,
|
||||
steps_list,
|
||||
cfgs,
|
||||
samplers,
|
||||
schedulers,
|
||||
end_steps,
|
||||
filename_prefixes,
|
||||
)
|
||||
|
||||
|
||||
# ---------- WAN 2.2 Image to Video (no scaling; expects pre-sized input) ----------
|
||||
class WanImageToVideoMXD:
|
||||
"""
|
||||
WAN 2.2 Image → Video (MXD)
|
||||
|
||||
- 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.
|
||||
⚙️ No scaling — expects pre-sized input.
|
||||
"""
|
||||
|
||||
TITLE = "WAN Image to Video MXD"
|
||||
CATEGORY = "conditioning/video_models"
|
||||
DESCRIPTION = "Image-to-video conditioning with Auto/480p/720p scaling and optional crop-to-fit."
|
||||
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"
|
||||
FUNCTION = "run"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -1140,11 +1180,9 @@ class WanImageToVideoMXD:
|
||||
"required": {
|
||||
"positive": ("CONDITIONING",),
|
||||
"negative": ("CONDITIONING",),
|
||||
"vae": ("VAE",),
|
||||
"length": ("INT", {"default": 81, "min": 1, "max": 16384, "step": 4}),
|
||||
"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",),
|
||||
@@ -1152,71 +1190,198 @@ class WanImageToVideoMXD:
|
||||
}
|
||||
}
|
||||
|
||||
# -------- internals --------
|
||||
@staticmethod
|
||||
def _pick_bucket(img_w, img_h, tier, crop_to_fit):
|
||||
in_ar = _ar(img_w, img_h)
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# -------- main --------
|
||||
def run(self, positive, negative, vae, length, batch_size,
|
||||
tier="Auto", crop_to_fit=False, clip_vision_output=None, start_image=None):
|
||||
clip_vision_output=None, start_image=None):
|
||||
|
||||
# Default when no start image
|
||||
if start_image is None:
|
||||
final_w, final_h = 832, 480
|
||||
else:
|
||||
_, 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))
|
||||
raise ValueError("start_image must be provided (already scaled).")
|
||||
|
||||
# Latent setup
|
||||
# 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, final_h // 8, final_w // 8],
|
||||
[batch_size, 16, t, ih // 8, iw // 8],
|
||||
device=comfy.model_management.intermediate_device()
|
||||
)
|
||||
|
||||
# Encode start image
|
||||
if start_image is not None:
|
||||
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
|
||||
# ▶ Build a full-length (length, H, W, C) tensor and copy the given frames
|
||||
image = torch.ones(
|
||||
(length, ih, iw, ch),
|
||||
device=start_image.device,
|
||||
dtype=start_image.dtype
|
||||
) * 0.5
|
||||
image[:frames_used] = start_image[:frames_used]
|
||||
|
||||
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=framed.device, dtype=framed.dtype
|
||||
)
|
||||
mask[:, :, :((framed.shape[0] - 1) // 4) + 1] = 0.0
|
||||
# ▶ Encode the full-length tensor so its latent T matches t
|
||||
concat_latent_image = vae.encode(image[:, :, :, :3])
|
||||
|
||||
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
|
||||
})
|
||||
# ▶ Make mask with T = t, and zero only the used frame-chunks
|
||||
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}
|
||||
)
|
||||
|
||||
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})
|
||||
|
||||
|
||||
# ---- 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
|
||||
SQUARE_TOL = 0.03 # ±3% aspect-ratio tolerance counts as "square-ish"
|
||||
|
||||
def _ar(w, h):
|
||||
return w / max(1, h)
|
||||
|
||||
def _safe_hw(w, h):
|
||||
w = max(16, min(w, nodes.MAX_RESOLUTION))
|
||||
h = max(16, min(h, nodes.MAX_RESOLUTION))
|
||||
return w, h
|
||||
|
||||
def _floor16(x):
|
||||
x = int(x) // 16 * 16
|
||||
return max(16, x)
|
||||
|
||||
def _ceil16(x):
|
||||
x = (int(x) + 15) // 16 * 16
|
||||
return max(16, x)
|
||||
|
||||
def _is_squareish(w, h, tol=SQUARE_TOL):
|
||||
r = _ar(w, h)
|
||||
return abs(r - 1.0) <= tol
|
||||
|
||||
def _closest_bucket(img_w, img_h, bucket_list, cover=False):
|
||||
"""
|
||||
Pick the best (bw,bh) from bucket_list for this image.
|
||||
Uses scale closeness + AR diff to rank.
|
||||
"""
|
||||
in_ar = _ar(img_w, img_h)
|
||||
best, best_key = None, (float("inf"), 0.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 target (ensures >= target on both sides after ceil16),
|
||||
then center-crop. No padding.
|
||||
"""
|
||||
t, ih, iw, c = img.shape
|
||||
s = max(out_w / iw, out_h / ih)
|
||||
tw = _ceil16(iw * s)
|
||||
th = _ceil16(ih * s)
|
||||
tmp = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1)
|
||||
y0 = max(0, (th - out_h) // 2)
|
||||
x0 = max(0, (tw - out_w) // 2)
|
||||
return tmp[:, y0:y0+out_h, x0:x0+out_w, :]
|
||||
|
||||
def _resize_fit_inside(img, out_w, out_h):
|
||||
"""
|
||||
Resize to fit inside target (ensures <= target on both sides via floor16),
|
||||
and return the resized tensor only. No padding.
|
||||
"""
|
||||
t, ih, iw, c = img.shape
|
||||
s = min(out_w / iw, out_h / ih)
|
||||
tw = _floor16(iw * s)
|
||||
th = _floor16(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
|
||||
|
||||
# ---------- WAN 2.2 Image Scaler (no padding; fit or crop modes; square-aware) ----------
|
||||
class WAN22_I2V_Image_Scaler_MXD:
|
||||
"""
|
||||
MXD Image Scaler for WAN 2.2 (NO PADDING)
|
||||
- Modes: Auto / 480p / 720p
|
||||
- Fit (no pad): proportional resize ≤ target; returns resized dims.
|
||||
- Crop (no pad): resize-to-cover then center-crop to exact target.
|
||||
- Square handling:
|
||||
* Auto: ~square → 624×624
|
||||
* 480p: ~square → 624×624
|
||||
* 720p: ~square → 720×720 (explicitly supported)
|
||||
"""
|
||||
|
||||
TITLE = "Image Bucket Scaler MXD (No Pad)"
|
||||
CATEGORY = "image/processing"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "scale"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
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)"}),
|
||||
}
|
||||
}
|
||||
|
||||
def _pick_bucket(self, iw, ih, tier, crop_to_fit):
|
||||
in_ar = _ar(iw, ih)
|
||||
is_squareish = abs(in_ar - 1.0) <= SQUARE_TOL
|
||||
is_landscape = iw >= ih
|
||||
|
||||
if tier == "720p":
|
||||
# --- Always force square → 720x720 ---
|
||||
if is_squareish:
|
||||
return (720, 720)
|
||||
|
||||
# --- Normal 16:9 / 9:16 handling ---
|
||||
buckets = BUCKETS_720.copy()
|
||||
if not is_squareish:
|
||||
# lock orientation
|
||||
buckets = [(1280, 720)] if is_landscape else [(720, 1280)]
|
||||
return _closest_bucket(iw, ih, buckets, cover=crop_to_fit)
|
||||
|
||||
if tier == "480p":
|
||||
buckets = BUCKETS_480.copy()
|
||||
if not is_squareish:
|
||||
buckets = [(832,480)] if is_landscape else [(480,832)]
|
||||
elif is_squareish:
|
||||
buckets.append((624,624))
|
||||
return _closest_bucket(iw, ih, buckets, cover=crop_to_fit)
|
||||
|
||||
# Auto mode
|
||||
if is_squareish:
|
||||
return (624,624)
|
||||
buckets = BUCKETS_720 if max(iw,ih)>720 else BUCKETS_480
|
||||
if not is_squareish:
|
||||
buckets = [(b[0],b[1]) for b in buckets if (b[0]>b[1]) == is_landscape]
|
||||
return _closest_bucket(iw, ih, buckets, cover=crop_to_fit)
|
||||
|
||||
def scale(self, image, tier="Auto", crop_to_fit=False):
|
||||
_, ih, iw, _ = image.shape
|
||||
bw, bh = self._pick_bucket(iw, ih, tier, crop_to_fit)
|
||||
# Buckets are canonical; ensure they are /16 and safe
|
||||
bw, bh = _safe_hw(_ceil16(bw), _ceil16(bh)) if crop_to_fit else _safe_hw(_floor16(bw), _floor16(bh))
|
||||
|
||||
if crop_to_fit:
|
||||
# Cover → center crop to exact (bw,bh). No padding.
|
||||
out = _resize_then_center_crop(image, bw, bh)
|
||||
else:
|
||||
# Fit inside → return resized (tw,th) only. No padding.
|
||||
out, _, _ = _resize_fit_inside(image, bw, bh)
|
||||
|
||||
return (out,)
|
||||
|
||||
# ---------- Node registration ----------
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -1227,7 +1392,9 @@ NODE_CLASS_MAPPINGS = {
|
||||
"wan22EmptyHunyuanLatentVideoMXD": wan22EmptyHunyuanLatentVideoMXD,
|
||||
"SaveLatent_I2V_MXD": SaveLatent_I2V_MXD,
|
||||
"LoadLatent_I2V_MXD": LoadLatent_I2V_MXD,
|
||||
"LoadLatents_FromFolder_I2V_MXD": LoadLatents_FromFolder_I2V_MXD,
|
||||
"WanImageToVideoMXD": WanImageToVideoMXD,
|
||||
"WAN22_I2V_Image_Scaler_MXD": WAN22_I2V_Image_Scaler_MXD,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -1238,5 +1405,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"wan22EmptyHunyuanLatentVideoMXD": "WAN2.2 Empty Latent Video MXD",
|
||||
"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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user