Spaces:
Running on Zero
Running on Zero
| import os | |
| import tempfile | |
| import spaces # must come before torch / any CUDA-touching import | |
| import numpy as np | |
| import torch | |
| import gradio as gr | |
| import decord | |
| import imageio | |
| from PIL import Image | |
| from omegaconf import OmegaConf | |
| from torchvision import transforms | |
| from einops import rearrange | |
| from huggingface_hub import snapshot_download, hf_hub_download | |
| from pipeline import CausalInferencePipeline | |
| from utils.misc import set_seed | |
| # ---------------------------------------------------------------------------- | |
| # Constants (mirror the released `infer-local-ar-forcing.sh` defaults) | |
| # ---------------------------------------------------------------------------- | |
| WAN_REPO = "Wan-AI/Wan2.1-T2V-1.3B" | |
| WAN_DIR = "wan_models/Wan2.1-T2V-1.3B" | |
| LIVEEDIT_REPO = "cp-cp/LiveEdit" | |
| CKPT_NAME = "ar-forcing_002000.pt" | |
| CONFIG_PATH = "configs/wan_mm-ar-forcing-local.yaml" | |
| DEFAULT_CONFIG_PATH = "configs/default_config.yaml" | |
| NUM_OUTPUT_FRAMES = 21 # latent frames -> (21-1)*4 + 1 = 81 pixel frames | |
| NUM_PIXEL_FRAMES = NUM_OUTPUT_FRAMES * 4 - 3 | |
| HEIGHT, WIDTH = 480, 832 | |
| FPS = 16 | |
| # ---------------------------------------------------------------------------- | |
| # Download weights (base Wan2.1-T2V-1.3B + the LiveEdit causal checkpoint) | |
| # ---------------------------------------------------------------------------- | |
| os.makedirs("wan_models", exist_ok=True) | |
| os.makedirs("checkpoints/liveedit", exist_ok=True) | |
| snapshot_download( | |
| repo_id=WAN_REPO, | |
| local_dir=WAN_DIR, | |
| allow_patterns=[ | |
| "config.json", | |
| "diffusion_pytorch_model.safetensors", | |
| "Wan2.1_VAE.pth", | |
| "models_t5_umt5-xxl-enc-bf16.pth", | |
| "google/umt5-xxl/*", | |
| ], | |
| ) | |
| CKPT_PATH = hf_hub_download( | |
| repo_id=LIVEEDIT_REPO, | |
| filename=CKPT_NAME, | |
| local_dir="checkpoints/liveedit", | |
| ) | |
| # ---------------------------------------------------------------------------- | |
| # Build the pipeline at module scope (ZeroGPU streams the eager .cuda weights | |
| # into VRAM on the first @spaces.GPU call). | |
| # ---------------------------------------------------------------------------- | |
| device = torch.device("cuda") | |
| set_seed(0) | |
| torch.set_grad_enabled(False) | |
| config = OmegaConf.merge( | |
| OmegaConf.load(DEFAULT_CONFIG_PATH), | |
| OmegaConf.load(CONFIG_PATH), | |
| ) | |
| # Few-step causal editing pipeline with the source-video conditioning channels | |
| # (patch embedding expanded 16 -> 32) exactly as in inference-mm.py. | |
| local_attn_size = -1 # NUM_OUTPUT_FRAMES (21) is not > 21, so global attention | |
| pipeline = CausalInferencePipeline( | |
| config, | |
| device=device, | |
| local_attn_size=local_attn_size, | |
| sink_size=0, | |
| expand_patch_embedding=True, | |
| ) | |
| def _remove_fsdp_wrapped_module(state_dict): | |
| cleaned = {} | |
| for key, value in state_dict.items(): | |
| if "_fsdp_wrapped_module" in key: | |
| new_key = "model." + key.split("._fsdp_wrapped_module.")[-1] | |
| cleaned[new_key] = value | |
| else: | |
| cleaned[key] = value | |
| return cleaned | |
| def _select_generator_state_dict(state_dict): | |
| if "generator" in state_dict: | |
| return state_dict["generator"] | |
| if "generator_ema" in state_dict: | |
| return state_dict["generator_ema"] | |
| return state_dict | |
| _raw = torch.load(CKPT_PATH, map_location="cpu") | |
| _gen = _select_generator_state_dict(_raw) | |
| pipeline.generator.load_state_dict(_remove_fsdp_wrapped_module(_gen)) | |
| pipeline = pipeline.to(dtype=torch.bfloat16) | |
| pipeline.text_encoder.to(device=device) | |
| pipeline.generator.to(device=device) | |
| pipeline.vae.to(device=device) | |
| _TRANSFORM = transforms.Compose([ | |
| transforms.Resize((HEIGHT, WIDTH)), | |
| transforms.ToTensor(), | |
| ]) | |
| def _read_source_video(video_path): | |
| """Load and resample a source video to NUM_PIXEL_FRAMES at 480x832. | |
| Returns a tensor of shape [1, C, T, H, W] in [0, 1]. | |
| """ | |
| vr = decord.VideoReader(video_path, ctx=decord.cpu(0)) | |
| total = len(vr) | |
| if total >= NUM_PIXEL_FRAMES: | |
| indices = np.arange(0, NUM_PIXEL_FRAMES) | |
| else: | |
| indices = np.linspace(0, total - 1, NUM_PIXEL_FRAMES, dtype=int) | |
| frames = vr.get_batch(indices).asnumpy() # (T, H, W, C) | |
| frames = [Image.fromarray(f) for f in frames] | |
| video = torch.stack([_TRANSFORM(f) for f in frames]) # [T, C, H, W] | |
| video = video.permute(1, 0, 2, 3).unsqueeze(0) # [1, C, T, H, W] | |
| return video | |
| def edit_video(video_path, instruction, seed, progress=gr.Progress(track_tqdm=True)): | |
| if not video_path: | |
| raise gr.Error("Please provide a source video.") | |
| if not instruction or not instruction.strip(): | |
| raise gr.Error("Please provide an editing instruction.") | |
| set_seed(int(seed)) | |
| source_pixel = _read_source_video(video_path).to(device=device, dtype=torch.bfloat16) | |
| source_latent = pipeline.vae.encode_to_latent(source_pixel).to(dtype=torch.bfloat16) | |
| noise = torch.randn( | |
| [1, NUM_OUTPUT_FRAMES, 16, 60, 104], device=device, dtype=torch.bfloat16, | |
| generator=torch.Generator(device=device).manual_seed(int(seed)), | |
| ) | |
| video, _ = pipeline.inference( | |
| noise=noise, | |
| text_prompts=[instruction.strip()], | |
| return_latents=True, | |
| initial_latent=None, | |
| y=source_latent, | |
| wo_scale=True, | |
| ) | |
| pipeline.vae.model.clear_cache() | |
| # [1, T, C, H, W] in [0, 1] -> uint8 [T, H, W, C] | |
| frames = rearrange(video, "b t c h w -> b t h w c")[0] | |
| frames = (frames.float().clamp(0, 1) * 255.0).byte().cpu().numpy() | |
| out_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name | |
| imageio.mimwrite(out_path, list(frames), fps=FPS, codec="libx264", quality=8) | |
| return out_path | |
| DESCRIPTION = """ | |
| # LiveEdit — Real-Time Diffusion-Based Streaming Video Editing | |
| Causal, chunk-by-chunk video editing built on **Wan2.1-T2V-1.3B** and the | |
| Self-Forcing codebase. Give it a source video and a text instruction; LiveEdit | |
| edits the video while preserving backgrounds and non-edited regions. | |
| [Project page](https://live-edit.github.io) · | |
| [Paper](https://arxiv.org/abs/2606.26740) · | |
| [Code](https://github.com/cp-cp/LiveEdit) · | |
| [Checkpoints](https://huggingface.co/cp-cp/LiveEdit) · ECCV 2026 | |
| The source video is resampled to 81 frames at 480×832; output is ~5s @ 16fps. | |
| """ | |
| with gr.Blocks(title="LiveEdit") as demo: | |
| gr.Markdown(DESCRIPTION) | |
| with gr.Row(): | |
| with gr.Column(): | |
| in_video = gr.Video(label="Source video", sources=["upload"]) | |
| instruction = gr.Textbox( | |
| label="Editing instruction", | |
| placeholder="e.g. Change the red currants to deep black grapes.", | |
| ) | |
| seed = gr.Slider(0, 2**31 - 1, value=0, step=1, label="Seed") | |
| run = gr.Button("Edit video", variant="primary") | |
| with gr.Column(): | |
| out_video = gr.Video(label="Edited video") | |
| gr.Examples( | |
| examples=[["test_cases/test.mp4", "Change the red currants to deep black grapes.", 0]], | |
| inputs=[in_video, instruction, seed], | |
| outputs=out_video, | |
| fn=edit_video, | |
| cache_examples=False, | |
| ) | |
| run.click(edit_video, inputs=[in_video, instruction, seed], outputs=out_video) | |
| if __name__ == "__main__": | |
| demo.launch() | |