cmd-i2v-demo / app.py
multimodalart's picture
multimodalart HF Staff
Pass pix_fmt once to ffmpeg
18e6be6 verified
Raw
History Blame Contribute Delete
10.7 kB
"""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))))
@spaces.GPU(duration=_estimate_duration)
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)