"""Loopy — Seamless Video Loop Generation via Anchored Looping Shift of Positional Embedding. Faithful ZeroGPU port of https://github.com/WeChatCV/Loopy (htdong/Loopy). The reference implementation is `generate_2.2_new.py` + `Wan2.2/wan2/text2video_roll.py` + `Wan2.2/wan2/modules/model_roll.py` from the official repo. It is Wan2.2-T2V-A14B (two-expert MoE DiT) with four LoRAs merged in: * LightX2V 4-step CFG-distillation LoRAs (high / low noise experts) * Loopy's own looping LoRAs (high / low noise experts) plus the paper's core trick, the *anchored looping shift of positional embedding*: inside every self-attention block `i` the temporal RoPE frequencies are cyclically rolled by `shift = 0 if i == 0 else (i - 1) % (F - 1) + 1` (F = number of latent frames). That makes the temporal positions live on a circle, so the last frame is a neighbour of the first one and the generated clip loops seamlessly. This app reproduces that logic on top of 🧨 diffusers' `WanPipeline` so it fits on ZeroGPU (fp8 dynamic quantization of both experts). """ import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 (must precede torch) import gc # noqa: E402 import random # noqa: E402 import re # noqa: E402 import tempfile # noqa: E402 import time # noqa: E402 import gradio as gr # noqa: E402 import numpy as np # noqa: E402 import torch # noqa: E402 from diffusers import AutoencoderKLWan, UniPCMultistepScheduler, WanPipeline # noqa: E402 from diffusers.models.transformers.transformer_wan import ( # noqa: E402 WanRotaryPosEmbed, WanTransformer3DModel, WanTransformerBlock, ) from diffusers.utils import export_to_video # noqa: E402 from huggingface_hub import hf_hub_download # noqa: E402 from safetensors.torch import load_file # noqa: E402 from torchao.quantization import ( # noqa: E402 Float8DynamicActivationFloat8WeightConfig, Int8WeightOnlyConfig, quantize_, ) # -------------------------------------------------------------------------------------- # Constants (all taken from the reference: test.sh + wan2/configs/wan_t2v_A14B.py) # -------------------------------------------------------------------------------------- BASE_REPO = "linoyts/Wan2.2-T2V-A14B-Diffusers-BF16" # bf16 mirror of Wan-AI/Wan2.2-T2V-A14B-Diffusers LOOPY_REPO = "htdong/Loopy" LIGHTX2V_REPO = "lightx2v/Wan2.2-Distill-Loras" LIGHTX2V_HIGH = "wan2.2_t2v_A14b_high_noise_lora_rank64_lightx2v_4step_1217.safetensors" LIGHTX2V_LOW = "wan2.2_t2v_A14b_low_noise_lora_rank64_lightx2v_4step_1217.safetensors" FIXED_FPS = 16 # wan_shared_cfg.sample_fps DEFAULT_FRAMES = 53 # test.sh --frame_num 53 DEFAULT_STEPS = 4 # test.sh --sample_steps 4 DEFAULT_SHIFT = 12.0 # t2v_A14B.sample_shift DEFAULT_GUIDANCE = 1.0 # t2v_A14B.sample_guide_scale = (1.0, 1.0) -> CFG disabled DEFAULT_REPEATS = 3 MAX_SEED = np.iinfo(np.int32).max # t2v_A14B / wan_shared_cfg.sample_neg_prompt (only used if guidance > 1) DEFAULT_NEGATIVE_PROMPT = ( "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰," "最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部," "画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面," "杂乱的背景,三条腿,背景人很多,倒着走" ) RESOLUTIONS = { "832 × 480 (landscape)": (832, 480), "480 × 832 (portrait)": (480, 832), } DEFAULT_RESOLUTION = "832 × 480 (landscape)" # -------------------------------------------------------------------------------------- # 1. Anchored looping shift of positional embedding # (port of rope_apply_loop / WanSelfAttention.forward in wan2/modules/model_roll.py) # -------------------------------------------------------------------------------------- class _RopeWithGrid(tuple): """`(freqs_cos, freqs_sin)` that also remembers the latent grid `(F, H, W)`.""" grid = None _orig_rope_forward = WanRotaryPosEmbed.forward def _rope_forward_with_grid(self, hidden_states: torch.Tensor): freqs = _orig_rope_forward(self, hidden_states) _, _, num_frames, height, width = hidden_states.shape p_t, p_h, p_w = self.patch_size out = _RopeWithGrid(freqs) out.grid = (num_frames // p_t, height // p_h, width // p_w) return out def _roll_rope(rope: _RopeWithGrid, shift: int): """Cyclically roll the temporal axis of the RoPE frequencies. Equivalent to `torch.roll(freqs_3d, shifts=time_shift, dims=0)` in `model_roll.rope_apply_loop`: the height/width components are constant along the frame axis, so rolling the whole concatenated frequency vector only affects the temporal part. """ if shift == 0: return (rope[0], rope[1]) f, h, w = rope.grid rolled = [] for freqs in (rope[0], rope[1]): dim = freqs.shape[-1] view = freqs.reshape(f, h, w, dim) view = torch.roll(view, shifts=shift, dims=0) rolled.append(view.reshape(1, f * h * w, 1, dim)) return tuple(rolled) def _loop_shift_for_block(block_idx: int, num_latent_frames: int) -> int: if block_idx == 0 or num_latent_frames <= 1: return 0 return (block_idx - 1) % (num_latent_frames - 1) + 1 _orig_block_forward = WanTransformerBlock.forward def _looping_block_forward(self, hidden_states, encoder_hidden_states, temb, rotary_emb): block_idx = getattr(self, "_loopy_block_idx", None) grid = getattr(rotary_emb, "grid", None) if block_idx is not None and grid is not None: rotary_emb = _roll_rope(rotary_emb, _loop_shift_for_block(block_idx, grid[0])) return _orig_block_forward(self, hidden_states, encoder_hidden_states, temb, rotary_emb) def enable_looping(transformer: WanTransformer3DModel) -> None: for i, block in enumerate(transformer.blocks): block._loopy_block_idx = i WanRotaryPosEmbed.forward = _rope_forward_with_grid WanTransformerBlock.forward = _looping_block_forward # -------------------------------------------------------------------------------------- # 2. LoRA merging (port of WanModel_roll.load_lora / lightx2v_lora_adapter.WanLoraWrapper) # Both reference loaders do `W += B @ A` with no alpha/rank rescaling. # -------------------------------------------------------------------------------------- _ATTN_PROJ = {"q": "to_q", "k": "to_k", "v": "to_v", "o": "to_out.0"} _LORA_KEY_RE = re.compile(r"^(?P.+?)\.lora_(?PA|B|down|up)(?:\.default)?\.weight$") def _native_to_diffusers(name: str): """`blocks.7.self_attn.q` -> `blocks.7.attn1.to_q` (same mapping diffusers uses).""" parts = name.split(".") if len(parts) < 3 or parts[0] != "blocks": return None idx, rest = parts[1], ".".join(parts[2:]) head = rest.split(".") if head[0] == "self_attn" and len(head) == 2 and head[1] in _ATTN_PROJ: return f"blocks.{idx}.attn1.{_ATTN_PROJ[head[1]]}" if head[0] == "cross_attn" and len(head) == 2 and head[1] in _ATTN_PROJ: return f"blocks.{idx}.attn2.{_ATTN_PROJ[head[1]]}" if rest == "ffn.0": return f"blocks.{idx}.ffn.net.0.proj" if rest == "ffn.2": return f"blocks.{idx}.ffn.net.2" return None @torch.no_grad() def merge_wan_lora(transformer: WanTransformer3DModel, path: str, scale: float = 1.0) -> None: state_dict = load_file(path) pairs: dict[str, dict[str, torch.Tensor]] = {} unmatched: list[str] = [] for key, value in state_dict.items(): base = key[len("diffusion_model.") :] if key.startswith("diffusion_model.") else key match = _LORA_KEY_RE.match(base) if match is None: unmatched.append(key) continue which = "A" if match.group("ab") in ("A", "down") else "B" pairs.setdefault(match.group("base"), {})[which] = value applied, skipped = 0, 0 for base, ab in sorted(pairs.items()): target = _native_to_diffusers(base) if target is None or "A" not in ab or "B" not in ab: skipped += 1 continue weight = transformer.get_submodule(target).weight down = ab["A"].to(device=weight.device, dtype=torch.float32) up = ab["B"].to(device=weight.device, dtype=torch.float32) delta = (up @ down) * scale if tuple(delta.shape) != tuple(weight.shape): raise ValueError(f"shape mismatch for {base} -> {target}: {delta.shape} vs {weight.shape}") weight.data.add_(delta.to(weight.dtype)) applied += 1 print( f"[lora] {os.path.basename(path)}: merged {applied} modules " f"(skipped {skipped}, unmatched keys {len(unmatched)})", flush=True, ) if unmatched: print(f"[lora] unmatched examples: {unmatched[:4]}", flush=True) if applied == 0: raise RuntimeError(f"No LoRA weights merged from {path}") del state_dict, pairs gc.collect() # -------------------------------------------------------------------------------------- # 3. Pipeline # # Ordering matters on ZeroGPU: every `.to("cuda")` / `device_map="cuda"` placement has to # happen *before* torchao quantization, because the ZeroGPU CUDA hijack cannot register # `Float8Tensor` subclasses (`aten.empty_like` is unimplemented for them). So: place on # cuda -> merge LoRAs in place -> quantize last. This mirrors the reference ZeroGPU Wan2.2 # Space (`zerogpu-aoti/wan2-2-fp8da-aoti-faster`). # -------------------------------------------------------------------------------------- print("[load] high-noise expert (bf16)", flush=True) transformer_high = WanTransformer3DModel.from_pretrained( BASE_REPO, subfolder="transformer", torch_dtype=torch.bfloat16, device_map="cuda" ) print("[load] low-noise expert (bf16)", flush=True) transformer_low = WanTransformer3DModel.from_pretrained( BASE_REPO, subfolder="transformer_2", torch_dtype=torch.bfloat16, device_map="cuda" ) vae = AutoencoderKLWan.from_pretrained(BASE_REPO, subfolder="vae", torch_dtype=torch.float32) pipe = WanPipeline.from_pretrained( BASE_REPO, transformer=transformer_high, transformer_2=transformer_low, vae=vae, torch_dtype=torch.bfloat16, ).to("cuda") # LightX2V 4-step distillation + Loopy LoRAs, merged into the matching expert. merge_wan_lora(pipe.transformer, hf_hub_download(LIGHTX2V_REPO, LIGHTX2V_HIGH)) merge_wan_lora(pipe.transformer, hf_hub_download(LOOPY_REPO, "high_noise.safetensors")) merge_wan_lora(pipe.transformer_2, hf_hub_download(LIGHTX2V_REPO, LIGHTX2V_LOW)) merge_wan_lora(pipe.transformer_2, hf_hub_download(LOOPY_REPO, "low_noise.safetensors")) enable_looping(pipe.transformer) enable_looping(pipe.transformer_2) print("[load] quantizing (int8 text encoder, fp8-dynamic experts)", flush=True) quantize_(pipe.text_encoder, Int8WeightOnlyConfig()) quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig()) quantize_(pipe.transformer_2, Float8DynamicActivationFloat8WeightConfig()) pipe.set_progress_bar_config(disable=False) gc.collect() print("[load] pipeline ready", flush=True) # -------------------------------------------------------------------------------------- # 4. Inference # -------------------------------------------------------------------------------------- def _estimate_duration( prompt="", resolution=DEFAULT_RESOLUTION, num_frames=DEFAULT_FRAMES, steps=DEFAULT_STEPS, *args, **kwargs, ): """ZeroGPU budget, fitted on measured runs of this Space: 53 frames / 832×480 / 4 steps -> 35.0 s ; 65 frames / 480×832 / 8 steps -> 81.0 s => ~0.575 s per (step x latent frame) at 832x480 plus ~9 s fixed, +15% margin. """ width, height = RESOLUTIONS.get(resolution, RESOLUTIONS[DEFAULT_RESOLUTION]) latent_frames = (int(num_frames) - 1) // 4 + 1 area = (width * height) / (832 * 480) return int((9.0 + 0.575 * int(steps) * latent_frames * area) * 1.15) + 1 @spaces.GPU(duration=_estimate_duration) def generate_loop( prompt: str, resolution: str = DEFAULT_RESOLUTION, num_frames: int = DEFAULT_FRAMES, steps: int = DEFAULT_STEPS, loop_repeats: int = DEFAULT_REPEATS, seed: int = 0, randomize_seed: bool = True, shift: float = DEFAULT_SHIFT, guidance_scale: float = DEFAULT_GUIDANCE, guidance_scale_2: float = DEFAULT_GUIDANCE, negative_prompt: str = DEFAULT_NEGATIVE_PROMPT, progress=gr.Progress(track_tqdm=True), ): """Generate a seamlessly looping video from a text prompt with Loopy (Wan2.2-T2V-A14B). Args: prompt: Text description of the scene. Works best with naturally periodic or continuous motion (falling snow, drifting clouds, a walking animal, a rotating object). English and Chinese are both supported. resolution: Output resolution, "832 × 480 (landscape)" or "480 × 832 (portrait)". num_frames: Number of frames in one loop, must be 4n+1 (53 = the paper's setting). steps: Denoising steps. 4 is what the LightX2V distillation LoRAs are trained for. loop_repeats: How many times the loop is repeated in the exported mp4 file, so the seam is visible when you scrub / download the video. seed: Random seed. randomize_seed: Draw a fresh random seed instead of using `seed`. shift: Flow-matching timestep shift (reference value: 12.0). guidance_scale: CFG scale for the high-noise expert. The distilled LoRAs expect 1.0 (no classifier-free guidance). guidance_scale_2: CFG scale for the low-noise expert (reference value: 1.0). negative_prompt: Only used when a guidance scale is above 1.0. progress: Gradio progress tracker. Returns: A tuple of (path to the generated looping mp4, seed used, timing report). """ if not prompt or not prompt.strip(): raise gr.Error("Please enter a prompt.") width, height = RESOLUTIONS.get(resolution, RESOLUTIONS[DEFAULT_RESOLUTION]) num_frames = int(num_frames) if (num_frames - 1) % 4 != 0: num_frames = ((num_frames - 1) // 4) * 4 + 1 used_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed) pipe.scheduler = UniPCMultistepScheduler.from_config( pipe.scheduler.config, flow_shift=float(shift) ) start = time.perf_counter() frames = pipe( prompt=prompt.strip(), negative_prompt=negative_prompt or "", height=height, width=width, num_frames=num_frames, num_inference_steps=int(steps), guidance_scale=float(guidance_scale), guidance_scale_2=float(guidance_scale_2), generator=torch.Generator(device="cuda").manual_seed(used_seed), ).frames[0] elapsed = time.perf_counter() - start repeats = max(1, int(loop_repeats)) with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle: video_path = handle.name export_to_video(list(frames) * repeats, video_path, fps=FIXED_FPS) report = ( f"{num_frames} frames @ {FIXED_FPS} fps ({num_frames / FIXED_FPS:.1f}s loop), " f"{width}×{height}, {int(steps)} steps — generated in {elapsed:.1f}s " f"(mp4 contains {repeats}× the loop)" ) print(f"[gen] {report}", flush=True) return video_path, used_seed, report # -------------------------------------------------------------------------------------- # 5. UI # -------------------------------------------------------------------------------------- # Prompts showcased by the authors on https://donghaotian123.github.io/Loopy/ # and in the reference repo's prompt.txt. EXAMPLE_PROMPTS = [ [ "An Arctic fox leaps nimbly through the snow while hunting, its white fur blending " "seamlessly with the snowflakes. Realistic style; a scene of winter wildlife." ], [ "A hummingbird hovers in front of a trumpet flower, its wings beating into a blur, " "while its long beak repeatedly dips into the flower's heart to sip nectar. Realistic style." ], [ "Auroras dance across the night sky, with green and purple light bands swirling like " "silk, while snow-capped mountain silhouettes stand silently. Wide shot." ], [ "A hawksbill turtle slowly flaps its front flippers, swimming through a turquoise coral " "sea, with bubbles continuously rising from its shell. Realistic style." ], [ "A potter presses and rotates a clay form with both hands, wet clay slowly rising and " "shaping between the fingers. Realistic style." ], [ "A tranquil lake reflects snow-capped mountains and sunset glow, with ripples spreading " "across the water as clouds slowly shift in the sky. Realistic style. Wide shot." ], ] CSS = """ .gradio-container .contain{max-width: 1080px !important; margin: 0 auto !important} """ with gr.Blocks() as demo: gr.Markdown( """ # 🔁 Loopy — seamless looping video generation [Loopy](https://huggingface.co/htdong/Loopy) makes [Wan2.2-T2V-A14B](https://huggingface.co/Wan-AI/Wan2.2-T2V-A14B) produce videos that **loop seamlessly**: the temporal RoPE frequencies are cyclically rolled per attention block (*anchored looping shift of positional embedding*), so the last frame becomes a neighbour of the first one. 4-step generation via the [LightX2V distillation LoRAs](https://huggingface.co/lightx2v/Wan2.2-Distill-Loras). *Prompt tip from the authors: describe motion that is naturally periodic or continuous — falling snow, drifting clouds, flowing water, a walking animal, a rotating object — and state the style and shot type. English and Chinese both work.* """ ) with gr.Row(): with gr.Column(scale=1): prompt = gr.Textbox( label="Prompt", placeholder="A hummingbird hovers in front of a trumpet flower… Realistic style.", lines=4, ) resolution = gr.Radio( label="Resolution", choices=list(RESOLUTIONS), value=DEFAULT_RESOLUTION, ) run = gr.Button("Generate looping video", variant="primary") with gr.Accordion("Advanced settings", open=False): num_frames = gr.Slider( label="Frames per loop (4n+1)", minimum=25, maximum=65, step=4, value=DEFAULT_FRAMES, info=f"{DEFAULT_FRAMES} frames ≈ {DEFAULT_FRAMES / FIXED_FPS:.1f}s at {FIXED_FPS} fps (paper setting).", ) steps = gr.Slider( label="Denoising steps", minimum=4, maximum=8, step=1, value=DEFAULT_STEPS ) loop_repeats = gr.Slider( label="Loop repeats in exported mp4", minimum=1, maximum=4, step=1, value=DEFAULT_REPEATS, ) seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0) randomize_seed = gr.Checkbox(label="Randomize seed", value=True) shift = gr.Slider( label="Timestep shift", minimum=1.0, maximum=16.0, step=0.5, value=DEFAULT_SHIFT ) guidance_scale = gr.Slider( label="Guidance scale — high-noise expert", minimum=1.0, maximum=6.0, step=0.5, value=DEFAULT_GUIDANCE, info="The distilled 4-step LoRAs are trained CFG-free; keep at 1.0.", ) guidance_scale_2 = gr.Slider( label="Guidance scale — low-noise expert", minimum=1.0, maximum=6.0, step=0.5, value=DEFAULT_GUIDANCE, ) negative_prompt = gr.Textbox( label="Negative prompt (only used when guidance > 1)", value=DEFAULT_NEGATIVE_PROMPT, lines=3, ) with gr.Column(scale=1): video = gr.Video( label="Looping video", autoplay=True, loop=True, interactive=False, ) info = gr.Markdown() inputs = [ prompt, resolution, num_frames, steps, loop_repeats, seed, randomize_seed, shift, guidance_scale, guidance_scale_2, negative_prompt, ] outputs = [video, seed, info] run.click(fn=generate_loop, inputs=inputs, outputs=outputs) prompt.submit(fn=generate_loop, inputs=inputs, outputs=outputs) gr.Examples( examples=EXAMPLE_PROMPTS, inputs=[prompt], outputs=outputs, fn=generate_loop, cache_examples=True, cache_mode="lazy", label="Prompts from the Loopy paper's showcase", ) if __name__ == "__main__": # Gradio 6 moved `theme` / `css` from the Blocks constructor to `launch()`. demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)