Spaces:
Running on Zero
Running on Zero
| """NVIDIA CMD (Context-Matched Distillation) image-to-video demo. | |
| This app follows the reference inference path of https://github.com/nv-tlabs/cmd | |
| (`inference.py`, `examples/run_examples.sh chunk1-short`) using the released | |
| `chunk1_short_t24_l21.safetensors` checkpoint from https://huggingface.co/nvidia/cmd. | |
| """ | |
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") | |
| import random | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| from typing import Optional | |
| import spaces # noqa: E402 (must be imported before torch) | |
| import torch # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| import imageio # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| from einops import rearrange # noqa: E402 | |
| from omegaconf import OmegaConf # noqa: E402 | |
| from PIL import Image # noqa: E402 | |
| from pipeline import CausalInferencePipeline # noqa: E402 | |
| from utils.misc import set_seed # noqa: E402 | |
| # -------------------------------------------------------------------------------------- | |
| # Released variant: "chunk1-short" from examples/run_examples.sh | |
| # -------------------------------------------------------------------------------------- | |
| MODEL_REPO = "nvidia/cmd" | |
| CHECKPOINT_FILE = "chunk1_short_t24_l21.safetensors" | |
| CONFIG_PATH = "configs/cosmos/t24_l21_student_context_distillation.yaml" | |
| DEFAULT_CONFIG_PATH = "configs/cosmos/default_config.yaml" | |
| MAX_LATENT_FRAMES = 24 # t24 | |
| MIN_LATENT_FRAMES = 6 | |
| NUM_FRAME_PER_BLOCK = 1 # chunk1 | |
| LOCAL_ATTN_SIZE = 21 # l21 | |
| FPS = 16 | |
| HEIGHT, WIDTH = 480, 832 | |
| DEFAULT_SEED = 22 # SEED default in examples/run_examples.sh | |
| # The Wan2.1 16-channel VAE that Cosmos-Predict2.5 ships as `tokenizer.pth`. | |
| # Sourced from the ungated Apache-2.0 Wan2.1 release instead; verified to load | |
| # into the vendored `_video_vae` with an exact state-dict match. | |
| VAE_REPO = "Wan-AI/Wan2.1-T2V-1.3B" | |
| VAE_CHECKPOINT_FILE = "Wan2.1_VAE.pth" | |
| torch.set_grad_enabled(False) | |
| def _pixel_frames(latent_frames: int) -> int: | |
| """Wan2.1 VAE temporal layout: 1 + 4*(n-1) pixel frames per n latent frames.""" | |
| return 1 + (int(latent_frames) - 1) * 4 | |
| print("Building the CMD chunk1-short pipeline...", flush=True) | |
| _config = OmegaConf.merge( | |
| OmegaConf.load(DEFAULT_CONFIG_PATH), OmegaConf.load(CONFIG_PATH) | |
| ) | |
| _config.num_frame_per_block = NUM_FRAME_PER_BLOCK | |
| _config.model_kwargs.local_attn_size = LOCAL_ATTN_SIZE | |
| # Build the DiT straight from the released CMD student export rather than | |
| # layering it over the gated Cosmos-Predict2.5-2B base checkpoint. | |
| _config.model_kwargs.model_name = MODEL_REPO | |
| _config.model_kwargs.checkpoint_filename = CHECKPOINT_FILE | |
| _config.vae_model_name = VAE_REPO | |
| _config.vae_checkpoint_filename = VAE_CHECKPOINT_FILE | |
| pipeline = CausalInferencePipeline(_config, device=torch.device("cuda")) | |
| pipeline = pipeline.to(dtype=torch.bfloat16) | |
| pipeline.text_encoder.to("cuda") | |
| pipeline.generator.to("cuda") | |
| pipeline.vae.to("cuda") | |
| print("Pipeline ready.", flush=True) | |
| def _preprocess(image: Image.Image) -> torch.Tensor: | |
| """Aspect-preserving centre crop to 832x480, then ToTensor + Normalize([0.5],[0.5]).""" | |
| image = image.convert("RGB") | |
| width, height = image.size | |
| target = WIDTH / HEIGHT | |
| if width / height > target: | |
| crop_w = int(round(height * target)) | |
| left = (width - crop_w) // 2 | |
| image = image.crop((left, 0, left + crop_w, height)) | |
| elif width / height < target: | |
| crop_h = int(round(width / target)) | |
| top = (height - crop_h) // 2 | |
| image = image.crop((0, top, width, top + crop_h)) | |
| image = image.resize((WIDTH, HEIGHT), Image.LANCZOS) | |
| array = np.asarray(image, dtype=np.float32) / 255.0 | |
| tensor = torch.from_numpy(array).permute(2, 0, 1) # [3, H, W] | |
| return (tensor - 0.5) / 0.5 | |
| def _write_mp4(frames: np.ndarray) -> str: | |
| path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name | |
| with imageio.get_writer( | |
| path, | |
| format="FFMPEG", | |
| mode="I", | |
| fps=FPS, | |
| codec="libx264", | |
| # pixelformat (not output_params) so ffmpeg receives a single -pix_fmt. | |
| pixelformat="yuv420p", | |
| output_params=["-crf", "17", "-movflags", "+faststart"], | |
| ) as writer: | |
| for frame in frames: | |
| writer.append_data(frame) | |
| return path | |
| def _estimate_duration( | |
| image=None, | |
| prompt: str = "", | |
| num_latent_frames: int = MAX_LATENT_FRAMES, | |
| seed: int = DEFAULT_SEED, | |
| randomize_seed: bool = False, | |
| *args, | |
| **kwargs, | |
| ) -> int: | |
| # Measured on this Space's ZeroGPU hardware (chunk1-short): 3.9s at t6, | |
| # 9.7s at t12, 16.3s at t18 and 23.7s at t24, plus ~1s of H.264 encoding. | |
| # Cost is linear in the latent-frame count; this keeps ~15% headroom over | |
| # the measured worst case and a small floor for the shortest clips, so the | |
| # request stays lean on every visitor's ZeroGPU quota. | |
| frames = int(num_latent_frames or MAX_LATENT_FRAMES) | |
| return max(15, min(60, int(round(1.36 * frames - 3.0)))) | |
| def generate( | |
| image: Optional[Image.Image], | |
| prompt: str, | |
| num_latent_frames: int = MAX_LATENT_FRAMES, | |
| seed: int = DEFAULT_SEED, | |
| randomize_seed: bool = False, | |
| ) -> tuple: | |
| """Animate a still image into a short video with NVIDIA CMD. | |
| Args: | |
| image: the first frame of the video (centre-cropped to 832x480). | |
| prompt: a description of the motion and scene to generate. | |
| num_latent_frames: video length in latent frames; n latents decode to 1+4*(n-1) frames at 16 fps. | |
| seed: RNG seed for reproducible sampling. | |
| randomize_seed: draw a fresh random seed instead of using `seed`. | |
| Returns: | |
| The generated mp4 path, a run-info string, and the seed that was used. | |
| """ | |
| if image is None: | |
| raise gr.Error("Please provide an input image to animate.") | |
| prompt = (prompt or "").strip() | |
| if not prompt: | |
| raise gr.Error("Please provide a text prompt describing the motion.") | |
| num_latent_frames = int(num_latent_frames) | |
| if not MIN_LATENT_FRAMES <= num_latent_frames <= MAX_LATENT_FRAMES: | |
| raise gr.Error( | |
| f"Video length must be between {MIN_LATENT_FRAMES} and {MAX_LATENT_FRAMES} latent frames." | |
| ) | |
| seed = random.randint(0, 2**31 - 1) if randomize_seed else int(seed) | |
| set_seed(seed) | |
| started = time.perf_counter() | |
| with torch.no_grad(): | |
| first_frame = ( | |
| _preprocess(image) | |
| .unsqueeze(0) | |
| .unsqueeze(2) | |
| .to(device="cuda", dtype=torch.bfloat16) | |
| ) # [1, 3, 1, H, W] | |
| initial_latent = pipeline.vae.encode_to_latent(first_frame).to( | |
| device="cuda", dtype=torch.bfloat16 | |
| ) | |
| noise = torch.randn( | |
| [1, num_latent_frames - 1, *_config.image_or_video_shape[2:]], | |
| device="cuda", | |
| dtype=torch.bfloat16, | |
| ) | |
| video, latents = pipeline.inference( | |
| noise=noise, | |
| text_prompts=[prompt], | |
| initial_latent=initial_latent, | |
| return_latents=True, | |
| ) | |
| frames = rearrange(video, "b t c h w -> b t h w c")[0].float().cpu() | |
| frames = (255.0 * frames).round().clamp(0, 255).to(torch.uint8).numpy() | |
| pipeline.vae.model.clear_cache() | |
| elapsed = time.perf_counter() - started | |
| path = _write_mp4(frames) | |
| info = ( | |
| f"{frames.shape[0]} frames ({frames.shape[0] / FPS:.1f}s) at {WIDTH}x{HEIGHT}, " | |
| f"{latents.shape[1]} latent frames · seed {seed} · {elapsed:.1f}s on GPU" | |
| ) | |
| print(info, flush=True) | |
| return path, info, seed | |
| EXAMPLES_DIR = Path("examples") | |
| def _example(name: str) -> list: | |
| return [ | |
| str(EXAMPLES_DIR / f"{name}.jpg"), | |
| (EXAMPLES_DIR / f"{name}.txt").read_text(encoding="utf-8").strip(), | |
| ] | |
| CSS = """ | |
| #col-container { max-width: 1100px; margin: 0 auto; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| """ | |
| with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.Markdown( | |
| """ | |
| # NVIDIA CMD — image to video | |
| Autoregressive image-to-video with | |
| [**nvidia/cmd**](https://huggingface.co/nvidia/cmd) (*Context-Matched | |
| Distillation*): a 4-step causal student distilled from | |
| [Cosmos-Predict2.5-2B](https://huggingface.co/nvidia/Cosmos-Predict2.5-2B), | |
| generating one latent frame at a time with a rolling KV cache. | |
| Give it a first frame and a prompt describing the motion. Released | |
| `chunk1-short` checkpoint · 832×480 · 16 fps · up to 93 frames. | |
| [Code](https://github.com/nv-tlabs/cmd) · | |
| [Model card](https://huggingface.co/nvidia/cmd) · | |
| Non-commercial use only (NVIDIA OneWay Noncommercial License). | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| image_in = gr.Image(label="First frame", type="pil", height=300) | |
| prompt_in = gr.Textbox( | |
| label="Prompt", | |
| lines=4, | |
| placeholder="Describe the scene and how it should move…", | |
| ) | |
| run_btn = gr.Button("Generate video", variant="primary") | |
| with gr.Column(): | |
| video_out = gr.Video(label="Generated video", autoplay=True, height=300) | |
| info_out = gr.Markdown() | |
| with gr.Accordion("Advanced settings", open=False): | |
| length_in = gr.Slider( | |
| label="Video length (latent frames)", | |
| minimum=MIN_LATENT_FRAMES, | |
| maximum=MAX_LATENT_FRAMES, | |
| step=1, | |
| value=MAX_LATENT_FRAMES, | |
| info="n latent frames decode to 1 + 4·(n−1) video frames at 16 fps " | |
| "(24 → 93 frames ≈ 5.8 s). Shorter is faster.", | |
| ) | |
| with gr.Row(): | |
| seed_in = gr.Number(label="Seed", value=DEFAULT_SEED, precision=0) | |
| randomize_in = gr.Checkbox(label="Randomize seed", value=False) | |
| gr.Examples( | |
| examples=[_example("bus_terminal"), _example("robot_welding")], | |
| inputs=[image_in, prompt_in], | |
| outputs=[video_out, info_out, seed_in], | |
| fn=generate, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| gr.on( | |
| triggers=[run_btn.click, prompt_in.submit], | |
| fn=generate, | |
| inputs=[image_in, prompt_in, length_in, seed_in, randomize_in], | |
| outputs=[video_out, info_out, seed_in], | |
| api_name="generate", | |
| ) | |
| demo.launch(mcp_server=True) | |