Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
230 lines
10 KiB
Python
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, "FunUI 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
|
|
|
|
|