Files
olivv-cs-ComfyUI-FunPack/modules/sampling/temporal_style/loop.py
T

230 lines
10 KiB
Python

"""Loop: seamless looping by rolling the latent in time (Mobius, arXiv:2502.20307).
On eligible denoise steps the latent is cyclically rolled along its time axis before the forward pass
and the prediction rolled back after, so the model meets the clip's seam (last frame | first frame) as an
interior pair at a step-varying position and smooths it like any other cut. No training, no extra
forward passes.
"""
import torch
from ..._core import log
# Seamless looping without training or extra forward passes: on eligible denoise steps
# the latent is cyclically rolled along its temporal dim before the forward and the
# prediction unrolled after. The model then repeatedly sees the video's seam
# (last frame | first frame) as an INTERIOR frame pair at a step-varying position and
# smooths it like any other cut — after enough steps the sequence is consistent under
# rotation, i.e. it loops. WanVideoWrapper's "Loop Args" is the same trick for WAN.
#
# Roll starts below the near-noise plateau: content is ~pure noise up there, so there is
# nothing to smooth. Appended guide frames (carry_i2v_guides / mid-scene guide / JoyAI memory /
# custom stacks) live at the TAIL of the same T range, pinned to absolute positions by
# keyframe_idxs — so only the content region [0, T - tail) rolls and the tail (plus
# keyframe_idxs and attention entries, which reference tail tokens) stays canonical.
# Guides keep informing every rolled position (their influence smears across the cycle),
# which is exactly what identity/style references should do in a loop; they are never
# rendered as frames themselves.
LOOP_ROLL_MAX_SIGMA = 0.95
LOOP_ROLL_MIN_FRAMES = 4 # below this many CONTENT latent frames there is nothing to loop
def _van_der_corput(n):
"""Base-2 bit-reversal sequence in (0,1): 0.5, 0.25, 0.75, 0.125, 0.625… — every
prefix covers the unit interval near-uniformly, so however many steps end up
eligible, the seam visits well-spread temporal positions."""
v, denom = 0.0, 1.0
n = int(n)
while n:
denom *= 2.0
v += (n & 1) / denom
n >>= 1
return v
def _loop_stream_shapes(args):
"""Per-stream latent shapes for a packed [B,1,N] input, or None when the layout
can't be trusted. Mirrors the samplers' packed-latent handling (video first, then
audio; both streams keep their temporal dim at index 2)."""
c = args.get("c") or {}
shapes = c.get("latent_shapes")
if hasattr(shapes, "cond"):
shapes = shapes.cond
x = args.get("input")
if x is None:
return None
if not shapes:
# Single-stream (plain LTXV) fed unpacked: treat the input itself as one stream.
return [tuple(int(d) for d in x.shape)] if x.ndim == 5 else None
try:
shapes = [tuple(int(d) for d in s) for s in shapes]
import math as _math
if sum(_math.prod(s[1:]) for s in shapes) != int(x.shape[-1]):
return None # packed layout doesn't match — don't risk rolling blind
except (TypeError, ValueError):
return None
return shapes
def _loop_content_shift(frac, t, tail):
"""Frame shift for a stream with `t` total frames of which the last `tail` are pinned
guide frames. 0 when the content region is too small to roll."""
tc = int(t) - max(0, int(tail))
if tc < 2:
return 0, tc
return int(round(frac * tc)) % tc, tc
def _loop_roll_stream(stream, frac, direction, tail):
"""Roll only the content region [0, T-tail) of one stream's temporal dim (dim 2);
the pinned guide tail stays canonical. Returns the input unchanged when there is
nothing to roll."""
shift, tc = _loop_content_shift(frac, stream.shape[2], tail)
if not shift:
return stream
out = stream.clone()
out[:, :, :tc] = torch.roll(stream[:, :, :tc], shifts=direction * shift, dims=2)
return out
def _loop_roll_packed(x, shapes, frac, direction, tails=None):
"""Cyclically roll every stream's content region of a packed [B,1,N] tensor by
round(frac * T_content) frames (video and audio each by their own content length, so
the two stay time-aligned). `tails` gives per-stream pinned tail-frame counts aligned
with `shapes` (appended guides / audio memory — left canonical). direction=+1 rolls,
-1 unrolls. Returns a new tensor."""
import math as _math
if x.ndim == 5: # unpacked single stream
return _loop_roll_stream(x, frac, direction, (tails or [0])[0])
out = x.clone()
off = 0
for i, dims in enumerate(shapes):
sz = _math.prod(dims[1:])
if len(dims) >= 3 and dims[2] >= 2:
stream = x[..., off:off + sz].reshape([x.shape[0]] + list(dims[1:]))
rolled = _loop_roll_stream(stream, frac, direction, (tails or [])[i] if tails else 0)
if rolled is not stream:
out[..., off:off + sz] = rolled.reshape(x.shape[0], 1, sz)
off += sz
return out
def _loop_roll_mask(mask, frac, direction, tail=0):
"""Roll a denoise mask ([B,1,T,H,W] video / [B,C,T,F] audio) in step with its stream,
leaving the pinned tail region canonical like the stream itself."""
if not isinstance(mask, torch.Tensor) or mask.ndim < 3 or mask.shape[2] < 2:
return mask
return _loop_roll_stream(mask, frac, direction, tail)
def _loop_video_tail_frames(c, video_shape):
"""Number of appended guide latent frames pinned at the video stream's tail. 0 when
the call carries no guides; None when a guide context exists but can't be parsed
(caller then leaves the call canonical rather than risking a corrupting roll).
keyframe_idxs is per-TOKEN — frames = tokens / (H*W), same math as comfy's
get_keyframe_idxs."""
kf = c.get("keyframe_idxs")
if kf is None:
# Attention entries without keyframe_idxs shouldn't happen; treat as unparseable.
return None if c.get("guide_attention_entries") else 0
try:
if len(video_shape) != 5 or not hasattr(kf, "shape") or len(kf.shape) < 3:
return None
tokens_per_frame = int(video_shape[-2]) * int(video_shape[-1])
n = int(kf.shape[2]) // max(1, tokens_per_frame)
return n if 0 < n < int(video_shape[2]) else None
except Exception:
return None
def _loop_audio_tail_frames(mask):
"""Appended audio-memory frames at the tail of the audio stream = the trailing run of
all-zero frames in its denoise mask (JoyAI paired audio memory rides mask=0; genuine
audio content is never fully masked). 0 when there is no mask or no zero tail."""
if not isinstance(mask, torch.Tensor) or mask.ndim < 3 or mask.shape[2] < 2:
return 0
try:
other_dims = [d for d in range(mask.ndim) if d != 2]
per_frame = mask.float().abs().amax(dim=other_dims) # [T]
tail = 0
for v in reversed(per_frame.tolist()):
if v > 1e-4:
break
tail += 1
return tail if tail < mask.shape[2] else 0
except Exception:
return 0
def make_loop_temporal_wrapper(old_wrapper):
"""Build the loop-style model_function_wrapper. Installed INNERMOST (closest to
apply_model): prediction-modifying wrappers layered above it (dynashift, output
guidance, …) must see canonical-orientation inputs and outputs — the roll exists
only for the duration of the base forward. The per-step shift follows a van der
Corput sequence, reset whenever sigma jumps back up (a new scene/run)."""
state = {"count": 0, "last_sigma": None, "logged": False}
def _loop_wrapper(apply_fn, args, _old=old_wrapper):
def _call(a):
if _old is not None:
return _old(apply_fn, a)
return apply_fn(a["input"], a["timestep"], **a.get("c", {}))
try:
ts = args.get("timestep")
sigma = float(ts.max().item()) if ts is not None else 1.0
except Exception:
sigma = 1.0
if state["last_sigma"] is not None and sigma > state["last_sigma"] + 1e-4:
state["count"], state["step_sigma"] = 0, None # sigma went back up: new scene/run
state["last_sigma"] = sigma
c = args.get("c") or {}
if sigma > LOOP_ROLL_MAX_SIGMA:
return _call(args)
shapes = _loop_stream_shapes(args)
if not shapes:
return _call(args)
# Per-stream pinned tails: appended guide frames (video, counted from the
# per-token keyframe_idxs) and appended audio memory (trailing mask=0 run).
# Only the content region in front of each tail rolls; the tail — and the
# keyframe_idxs / attention entries that reference it — stays canonical.
video_shape = next((s for s in shapes if len(s) == 5), shapes[0])
v_tail = _loop_video_tail_frames(c, video_shape)
if v_tail is None:
return _call(args) # guide context we can't parse: never risk a bad roll
tails = [
(v_tail if len(s) == 5 else _loop_audio_tail_frames(c.get("audio_denoise_mask")))
for s in shapes
]
t_content = int(video_shape[2]) - v_tail if len(video_shape) >= 3 else 0
if t_content < LOOP_ROLL_MIN_FRAMES:
return _call(args)
if state.get("step_sigma") is None or abs(sigma - state["step_sigma"]) > 1e-6:
state["count"] += 1 # a new step; cond and uncond calls of one step share a roll
state["step_sigma"] = sigma
state["frac"] = _van_der_corput(state["count"])
frac = state["frac"]
if not state["logged"]:
state["logged"] = True
tail_note = f", {v_tail} guide frame(s) pinned" if v_tail else ""
log.once("temporal_style:loop", log.INFO, "FunPack Temporal style", f"loop: Mobius latent roll active (T={t_content}{tail_note})")
# A roll or unroll that fails is not caught here: the guard records it, drops the loop for the
# run and reruns the call without it. The model's own errors pass through the same guard.
new_c = dict(c)
if isinstance(new_c.get("denoise_mask"), torch.Tensor):
new_c["denoise_mask"] = _loop_roll_mask(new_c["denoise_mask"], frac, 1, tail=v_tail)
if isinstance(new_c.get("audio_denoise_mask"), torch.Tensor):
new_c["audio_denoise_mask"] = _loop_roll_mask(
new_c["audio_denoise_mask"], frac, 1,
tail=_loop_audio_tail_frames(new_c["audio_denoise_mask"]))
rolled = dict(args)
rolled["input"] = _loop_roll_packed(args["input"], shapes, frac, 1, tails=tails)
rolled["c"] = new_c
return _loop_roll_packed(_call(rolled), shapes, frac, -1, tails=tails)
return _loop_wrapper