Files
olivv-cs-ComfyUI-FunPack/tests/test_loop_style.py
T
digital-garbageandClaude Fable 5 0214144a9d Loop temporal style: Mobius latent roll — seamless looping on LTX2.3
The 'loop' temporal style (in the style list since forever, but inert) now does
what WanVideoWrapper's Loop Args does for WAN: on eligible denoise steps the
packed latent is cyclically rolled along its temporal dim before the forward and
the prediction unrolled after, so the model repeatedly sees the video's seam
(last|first frame) as an interior frame pair at step-varying positions (van der
Corput sequence, reset per scene) and smooths it into a natural loop. Video and
audio streams roll by their own frame counts and stay time-aligned; denoise
masks roll in step, so i2v anchor per-token timesteps stay consistent.

Near-zero overhead: two clones + torch.roll per eligible step, no extra forward
passes. Rolls only below the near-noise plateau (nothing to smooth up there, and
the plateau step-cache stays valid). Calls carrying guides are left canonical —
keyframe_idxs pins appended guide frames to absolute positions inside the same
T range, so rolling them would corrupt; on guide-carrying scenes the roll is
inert and the run mechanism log says so.

Wiring: concrete style via the global build_enhancements path (innermost
relative to per-scene wrappers, as required — prediction-modifying wrappers see
canonical orientation only); auto director's funpack_temporal_loop via a tagged
per-scene wrapper on the Scene Chain sampler, installed below the guidance
stack. Editor already exposes 'loop' in the temporal style dropdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:20:04 +03:00

166 lines
6.0 KiB
Python

"""Unit tests for the loop temporal style (Mobius latent roll, arXiv:2502.20307).
Covers: per-stream temporal roll of the packed [B,1,N] latent (video and audio each by
their own frame count), exact unroll round-trips, denoise-mask rolls staying in step,
the eligibility gates (near-noise plateau, guide-pinned calls, tiny clips, unreadable
packed layouts), van der Corput shift spreading with per-scene reset, and wrapper
chaining semantics (inner wrappers see rolled args, callers always get canonical
predictions back).
"""
import math
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from ltx_enhancements import ( # noqa: E402
_loop_roll_mask,
_loop_roll_packed,
_loop_stream_shapes,
_van_der_corput,
make_loop_temporal_wrapper,
)
VSHAPE, ASHAPE = (1, 128, 16, 4, 6), (1, 8, 32, 16)
def _packed():
video = torch.arange(math.prod(VSHAPE), dtype=torch.float32).reshape(VSHAPE)
audio = torch.arange(math.prod(ASHAPE), dtype=torch.float32).reshape(ASHAPE) + 1e6
packed = torch.cat([video.reshape(1, 1, -1), audio.reshape(1, 1, -1)], dim=-1)
return video, audio, packed
def _args(sigma, packed, mask=None, extra_c=None):
c = {"latent_shapes": [torch.Size(VSHAPE), torch.Size(ASHAPE)]}
if mask is not None:
c["denoise_mask"] = mask
if extra_c:
c.update(extra_c)
return {"input": packed, "timestep": torch.tensor([sigma]), "c": c}
def test_van_der_corput_prefix():
assert [_van_der_corput(n) for n in range(1, 6)] == [0.5, 0.25, 0.75, 0.125, 0.625]
def test_roll_per_stream_and_round_trip():
video, audio, packed = _packed()
rolled = _loop_roll_packed(packed, [VSHAPE, ASHAPE], 0.5, 1)
assert torch.equal(rolled[..., : video.numel()].reshape(VSHAPE), torch.roll(video, 8, dims=2))
assert torch.equal(rolled[..., video.numel():].reshape(ASHAPE), torch.roll(audio, 16, dims=2))
assert torch.equal(_loop_roll_packed(rolled, [VSHAPE, ASHAPE], 0.5, -1), packed)
def test_roll_fraction_keeps_streams_time_aligned():
video, audio, packed = _packed()
rolled = _loop_roll_packed(packed, [VSHAPE, ASHAPE], 0.25, 1)
assert torch.equal(rolled[..., : video.numel()].reshape(VSHAPE), torch.roll(video, 4, dims=2))
assert torch.equal(rolled[..., video.numel():].reshape(ASHAPE), torch.roll(audio, 8, dims=2))
def test_mask_roll_round_trip():
mask = torch.rand(1, 1, 16, 4, 6)
rolled = _loop_roll_mask(mask, 0.5, 1)
assert torch.equal(rolled, torch.roll(mask, 8, dims=2))
assert torch.equal(_loop_roll_mask(rolled, 0.5, -1), mask)
def test_stream_shapes_refuses_mismatched_packing():
_, _, packed = _packed()
ok = _loop_stream_shapes(_args(0.7, packed))
assert ok == [VSHAPE, ASHAPE]
assert _loop_stream_shapes(_args(0.7, packed[..., :-5])) is None
def test_wrapper_rolls_in_unrolls_out():
video, _, packed = _packed()
mask = torch.rand(1, 1, 16, 4, 6)
seen = {}
def fake_apply(x, ts, **c):
seen["input"], seen["mask"] = x, c.get("denoise_mask")
return x * 1.0
w = make_loop_temporal_wrapper(None)
out = w(fake_apply, _args(0.909, packed, mask))
assert torch.equal(seen["input"][..., : video.numel()].reshape(VSHAPE), torch.roll(video, 8, dims=2))
assert torch.equal(seen["mask"], torch.roll(mask, 8, dims=2))
assert torch.equal(out, packed) # canonical on the way out
# second eligible step advances the shift sequence (0.25 -> 4 frames)
w(fake_apply, _args(0.725, packed, mask))
assert torch.equal(seen["input"][..., : video.numel()].reshape(VSHAPE), torch.roll(video, 4, dims=2))
def test_wrapper_gates_plateau_guides_and_tiny_clips():
video, _, packed = _packed()
seen = {}
def fake_apply(x, ts, **c):
seen["input"] = x
return x * 1.0
w = make_loop_temporal_wrapper(None)
w(fake_apply, _args(0.99, packed)) # near-noise plateau
assert torch.equal(seen["input"], packed)
w(fake_apply, _args(0.725, packed, extra_c={"keyframe_idxs": torch.zeros(1, 1)}))
assert torch.equal(seen["input"], packed)
w(fake_apply, _args(0.725, packed, extra_c={"guide_attention_entries": [object()]}))
assert torch.equal(seen["input"], packed)
tiny_shape = (1, 128, 2, 4, 6)
tiny = torch.rand(1, 1, math.prod(tiny_shape[1:]))
w2 = make_loop_temporal_wrapper(None)
w2(fake_apply, {"input": tiny, "timestep": torch.tensor([0.7]),
"c": {"latent_shapes": [torch.Size(tiny_shape)]}})
assert torch.equal(seen["input"], tiny)
def test_wrapper_resets_shift_sequence_on_new_scene():
video, _, packed = _packed()
seen = {}
def fake_apply(x, ts, **c):
seen["input"] = x
return x * 1.0
w = make_loop_temporal_wrapper(None)
w(fake_apply, _args(0.909, packed)) # frac 0.5
w(fake_apply, _args(0.725, packed)) # frac 0.25
w(fake_apply, _args(0.99, packed)) # sigma jumps back up: new scene
w(fake_apply, _args(0.909, packed)) # sequence restarts at 0.5
assert torch.equal(seen["input"][..., : video.numel()].reshape(VSHAPE), torch.roll(video, 8, dims=2))
def test_wrapper_unpacked_single_stream():
seen = {}
def fake_apply(x, ts, **c):
seen["input"] = x
return x * 1.0
five_d = torch.arange(math.prod(VSHAPE), dtype=torch.float32).reshape(VSHAPE)
w = make_loop_temporal_wrapper(None)
out = w(fake_apply, {"input": five_d, "timestep": torch.tensor([0.7]), "c": {}})
assert torch.equal(seen["input"], torch.roll(five_d, 8, dims=2))
assert torch.equal(out, five_d)
def test_wrapper_chain_inner_sees_rolled_caller_sees_canonical():
_, _, packed = _packed()
seen = {}
def fake_apply(x, ts, **c):
return x * 1.0
def inner(apply_fn, args):
seen["inner_input"] = args["input"]
return apply_fn(args["input"], args["timestep"], **args.get("c", {}))
w = make_loop_temporal_wrapper(inner)
out = w(fake_apply, _args(0.725, packed))
assert not torch.equal(seen["inner_input"], packed)
assert torch.equal(out, packed)