Files
2026-03-23 23:53:25 +01:00

405 lines
16 KiB
Python

import math
import torch
import torch.nn.functional as F
import comfy.clip_vision
import comfy.model_management as mm
import comfy.utils
import node_helpers
def _normalize_image_output(decoded):
"""Convert decoded VAE output to ComfyUI's standard IMAGE shape."""
if decoded.ndim == 5 and decoded.shape[0] == 1:
return decoded[0]
return decoded
def _find_coprime_skip(n):
"""Find a shift_skip value > n//2 that is coprime with n, ensuring full position coverage."""
candidate = n // 2 + 1
while candidate < n:
if math.gcd(candidate, n) == 1:
return candidate
candidate += 1
return 1
class MobiusLatentShiftWrapper:
"""
Mobius latent shift adapted for WAN FLF conditioning.
Rolls the noisy latent each step so the model sees varying temporal RoPE positions.
Optional concat-conditioning rolling can be enabled experimentally when desired.
"""
def __init__(self, shift_skip, latent_frames, roll_concat_conditioning=False):
self.shift_skip = shift_skip
self.latent_frames = latent_frames
self.shift_idx = 0
self.active_shift = 0
self.last_sigma = None
self.roll_concat_conditioning = roll_concat_conditioning
def __call__(self, apply_model_func, args_dict):
sigma = args_dict["timestep"]
if self.last_sigma is None or not torch.equal(sigma, self.last_sigma):
self.last_sigma = sigma.clone()
self.active_shift = self.shift_idx
self.shift_idx = (self.shift_idx + self.shift_skip) % self.latent_frames
if self.active_shift == 0:
return apply_model_func(args_dict["input"], args_dict["timestep"], **args_dict["c"])
shift = -self.active_shift
x = torch.roll(args_dict["input"], shift, dims=2)
c = dict(args_dict["c"])
# Rolling FLF concat conditioning makes the fixed-frame constraints move
# through time, which can destabilize the tail of the clip.
if self.roll_concat_conditioning and "c_concat" in c and c["c_concat"] is not None:
c_concat = c["c_concat"]
if hasattr(c_concat, "cond"):
rolled = torch.roll(c_concat.cond, shift, dims=2)
c["c_concat"] = c_concat.__class__(rolled)
else:
c["c_concat"] = torch.roll(c_concat, shift, dims=2)
output = apply_model_func(x, args_dict["timestep"], **c)
return torch.roll(output, -shift, dims=2)
class LoopBoundaryBlend:
"""
Post-CFG callback that blends end-frame predictions toward start-frame predictions.
For a seamless loop (start == end image), the denoised predictions at the temporal
boundaries should converge. This corrects the known WAN end-frame noise artifact
by gradually replacing the last K frames' predictions with the first K frames'.
"""
def __init__(self, blend_frames, blend_strength):
self.blend_frames = blend_frames
self.blend_strength = blend_strength
def __call__(self, args):
denoised = args["denoised"]
T = denoised.shape[2]
K = min(self.blend_frames, T // 4)
if K <= 0 or self.blend_strength <= 0:
return denoised
ramp = torch.linspace(0.0, self.blend_strength, K, device=denoised.device, dtype=denoised.dtype)
ramp = ramp.view(1, 1, K, 1, 1)
result = denoised.clone()
result[:, :, -K:] = denoised[:, :, -K:] * (1.0 - ramp) + denoised[:, :, :K] * ramp
return result
class SmartMobiusWanLoop:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_high": ("MODEL",),
"model_low": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"vae": ("VAE",),
"width": ("INT", {"default": 832, "min": 16, "max": 8192, "step": 16}),
"height": ("INT", {"default": 480, "min": 16, "max": 8192, "step": 16}),
"length": ("INT", {"default": 81, "min": 1, "max": 8192, "step": 4}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}),
},
"optional": {
"start_image": ("IMAGE",),
"end_image": ("IMAGE",),
"clip_vision_start_image": ("CLIP_VISION_OUTPUT",),
"clip_vision_end_image": ("CLIP_VISION_OUTPUT",),
"motion_amplitude": (
"FLOAT",
{"default": 1.0, "min": 1.0, "max": 2.0, "step": 0.05,
"tooltip": "1.0 = official FLF, 2.0 = max structural repulsion boost"},
),
"enable_latent_shift": (
"BOOLEAN",
{"default": True,
"tooltip": "Mobius-style circular latent rolling each denoising step"},
),
"shift_skip": (
"INT",
{"default": 0, "min": 0, "max": 100, "step": 1,
"tooltip": "Roll stride per step. 0 = auto (coprime with latent frame count)"},
),
"enable_loop_blend": (
"BOOLEAN",
{"default": True,
"tooltip": "Blend end-frame predictions toward start-frame predictions after CFG"},
),
"blend_frames": (
"INT",
{"default": 5, "min": 1, "max": 20, "step": 1,
"tooltip": "Number of end frames to correct via boundary blending"},
),
"blend_strength": (
"FLOAT",
{"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "0 = no correction, 1 = fully replace end with start predictions"},
),
"enable_circular_rope": (
"BOOLEAN",
{"default": True,
"tooltip": "Compress temporal RoPE so last frame is closer to first"},
),
"roll_concat_conditioning": (
"BOOLEAN",
{
"default": False,
"tooltip": "Experimental. Roll concat_mask/concat_latent_image together with the latent cycle."
},
),
"circular_rope_strength": (
"FLOAT",
{"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "0 = no change, 1 = maximum temporal compression"},
),
},
}
RETURN_TYPES = ("MODEL", "MODEL", "CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("model_high", "model_low", "positive", "negative", "latent")
FUNCTION = "execute"
CATEGORY = "SmartHelperNodes/Wan"
DESCRIPTION = (
"Mobius seamless loop for WAN 2.2 dual-pass (high/low noise). "
"Three independent mechanisms: (1) Latent shift — cycles frame positions across "
"denoising steps to distribute RoPE bias. (2) Loop blend — corrects end-frame noise "
"by blending toward start-frame predictions. (3) Circular RoPE — compresses temporal "
"positions so the last frame is closer to the first in attention space."
)
def execute(
self,
model_high,
model_low,
positive,
negative,
vae,
width,
height,
length,
batch_size,
start_image=None,
end_image=None,
clip_vision_start_image=None,
clip_vision_end_image=None,
motion_amplitude=1.0,
enable_latent_shift=True,
shift_skip=0,
enable_loop_blend=True,
blend_frames=5,
blend_strength=0.5,
enable_circular_rope=True,
roll_concat_conditioning=False,
circular_rope_strength=0.5,
):
spacial_scale = vae.spacial_compression_encode()
latent_frames = ((length - 1) // 4) + 1
latent = torch.zeros(
[batch_size, vae.latent_channels, latent_frames, height // spacial_scale, width // spacial_scale],
device=mm.intermediate_device(),
)
# --- Image preprocessing ---
if start_image is not None:
start_image = comfy.utils.common_upscale(
start_image[:length].movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
if end_image is not None:
end_image = comfy.utils.common_upscale(
end_image[-length:].movedim(-1, 1), width, height, "bilinear", "center"
).movedim(1, -1)
# --- Build reference image sequence (gray fill + start/end frames) ---
official_image = torch.ones((length, height, width, 3), device=mm.intermediate_device()) * 0.5
mask = torch.ones(
(1, 1, latent_frames * 4, height // spacial_scale, width // spacial_scale),
device=mm.intermediate_device(),
)
if start_image is not None:
official_image[:start_image.shape[0]] = start_image
mask[:, :, :start_image.shape[0] + 3] = 0.0
if end_image is not None:
official_image[-end_image.shape[0]:] = end_image
mask[:, :, -end_image.shape[0]:] = 0.0
official_latent = vae.encode(official_image[:, :, :, :3])
# --- Inverse structural repulsion (PainterFLF2V algorithm) ---
if length > 2 and motion_amplitude > 1.001 and start_image is not None and end_image is not None:
start_l = official_latent[:, :, 0:1]
end_l = official_latent[:, :, -1:]
t = torch.linspace(0.0, 1.0, official_latent.shape[2], device=official_latent.device).view(1, 1, -1, 1, 1)
linear_latent = start_l * (1 - t) + end_l * t
diff = official_latent - linear_latent
h, w = diff.shape[-2], diff.shape[-1]
low_freq_diff = F.interpolate(
diff.view(-1, vae.latent_channels, h, w), size=(max(1, h // 8), max(1, w // 8)), mode="area"
)
low_freq_diff = F.interpolate(low_freq_diff, size=(h, w), mode="bilinear")
low_freq_diff = low_freq_diff.view_as(diff)
high_freq_diff = diff - low_freq_diff
boost_scale = (motion_amplitude - 1.0) * 4.0
concat_latent_image = official_latent + (high_freq_diff * boost_scale)
else:
concat_latent_image = official_latent
# --- Mask reshape: [1,1,T*4,H,W] -> [1,4,T,H,W] ---
mask = mask.view(1, mask.shape[2] // 4, 4, mask.shape[3], mask.shape[4]).transpose(1, 2)
# --- Inject conditioning ---
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}
)
# --- CLIP vision merge ---
clip_vision_output = None
if clip_vision_start_image is not None:
clip_vision_output = clip_vision_start_image
if clip_vision_end_image is not None:
if clip_vision_output is not None:
states = torch.cat(
[clip_vision_output.penultimate_hidden_states,
clip_vision_end_image.penultimate_hidden_states],
dim=-2,
)
clip_vision_output = comfy.clip_vision.Output()
clip_vision_output.penultimate_hidden_states = states
else:
clip_vision_output = clip_vision_end_image
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})
# --- Model patching (shared state across both models) ---
patched_high = model_high.clone()
patched_low = model_low.clone()
if enable_latent_shift:
effective_skip = shift_skip if shift_skip > 0 else _find_coprime_skip(latent_frames)
if math.gcd(effective_skip, latent_frames) != 1:
effective_skip = _find_coprime_skip(latent_frames)
wrapper = MobiusLatentShiftWrapper(
effective_skip,
latent_frames,
roll_concat_conditioning=roll_concat_conditioning,
)
patched_high.set_model_unet_function_wrapper(wrapper)
patched_low.set_model_unet_function_wrapper(wrapper)
if enable_loop_blend and blend_strength > 0 and blend_frames > 0:
blend_fn = LoopBoundaryBlend(blend_frames, blend_strength)
patched_high.set_model_sampler_post_cfg_function(blend_fn)
patched_low.set_model_sampler_post_cfg_function(blend_fn)
if enable_circular_rope and circular_rope_strength > 0.0:
scale_t = 1.0 - (circular_rope_strength / latent_frames)
for m in (patched_high, patched_low):
to = m.model_options.get("transformer_options", {})
existing_rope = to.get("rope_options", {})
existing_rope["scale_t"] = scale_t
to["rope_options"] = existing_rope
m.model_options["transformer_options"] = to
out_latent = {"samples": latent}
return (patched_high, patched_low, positive, negative, out_latent)
class SmartMobiusVAEDecode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"samples": ("LATENT",),
"vae": ("VAE",),
},
"optional": {
"enable_frame_invariance": (
"BOOLEAN",
{"default": True, "tooltip": "Decode boundary region with wrapped context to fix 3D VAE seam artifacts"},
),
"boundary_frames": (
"INT",
{"default": 3, "min": 2, "max": 5, "step": 1,
"tooltip": "Latent frames to wrap at each boundary side"},
),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "execute"
CATEGORY = "SmartHelperNodes/Wan"
DESCRIPTION = (
"VAE decode with optional frame-invariance for seamless loops. "
"Wraps boundary latent frames and decodes them with full temporal context "
"to eliminate 3D VAE seam artifacts at the loop point."
)
def execute(self, samples, vae, enable_frame_invariance=True, boundary_frames=3):
latent = samples["samples"]
if not enable_frame_invariance or latent.shape[2] < boundary_frames * 2 + 1:
decoded = _normalize_image_output(vae.decode(latent))
return (decoded,)
k = boundary_frames
main_pixels = vae.decode(latent)
boundary_latent = torch.cat([latent[:, :, -k:], latent[:, :, :k]], dim=2)
boundary_pixels = vae.decode(boundary_latent)
frame_dim = 1 if boundary_pixels.ndim == 5 else 0
left_pixel_count = 1 + (k - 1) * 4
tail_slice = [slice(None)] * boundary_pixels.ndim
tail_slice[frame_dim] = slice(0, left_pixel_count)
tail_good = boundary_pixels[tuple(tail_slice)]
head_slice = [slice(None)] * boundary_pixels.ndim
head_slice[frame_dim] = slice(left_pixel_count, None)
head_good = boundary_pixels[tuple(head_slice)]
result = main_pixels.clone()
frame_dim = 1 if result.ndim == 5 else 0
tail_slice = [slice(None)] * result.ndim
tail_slice[frame_dim] = slice(-tail_good.shape[frame_dim], None)
result[tuple(tail_slice)] = tail_good
head_slice = [slice(None)] * result.ndim
head_slice[frame_dim] = slice(0, head_good.shape[frame_dim])
result[tuple(head_slice)] = head_good
return (_normalize_image_output(result),)
NODE_CLASS_MAPPINGS = {
"SmartMobiusWanLoop": SmartMobiusWanLoop,
"SmartMobiusVAEDecode": SmartMobiusVAEDecode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SmartMobiusWanLoop": "Smart Mobius WAN Loop",
"SmartMobiusVAEDecode": "Smart Mobius VAE Decode",
}