"""MiniWorld — camera-controlled video world model simulator (ZeroGPU). Mirrors the authors' reference inference path python -m miniworld.sample --dataset re10k --custom_camera_trajectory ... one-to-one: a single init image is Wan2.2-VAE-encoded into the clean seed latent, a procedural camera path is turned into ray-encoding conditioning, and the AR-diffusion denoiser rolls the world forward chunk-by-chunk with a position-bounded streaming KV cache and streaming VAE decode. """ from __future__ import annotations import math import os import tempfile import time os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # noqa: E402 (must precede any torch / CUDA work) import gradio as gr # noqa: E402 import numpy as np # noqa: E402 import torch # noqa: E402 from einops import rearrange # noqa: E402 from huggingface_hub import hf_hub_download # noqa: E402 from PIL import Image # noqa: E402 # MiniWorld checkpoints are plain `torch.save` dicts that carry a `meta` blob of # plain-python objects next to the tensors, so they need the full unpickler. _ORIG_TORCH_LOAD = torch.load def _torch_load(*args, **kwargs): kwargs.setdefault("weights_only", False) return _ORIG_TORCH_LOAD(*args, **kwargs) torch.load = _torch_load from miniworld.conditioning.actions import ( # noqa: E402 ConditioningConfig, build_cond_seq_for_batch, ) from miniworld.conditioning.trajectories import build_custom_trajectory # noqa: E402 from miniworld.denoiser import DenoiserConfig, build_denoiser_from_mode # noqa: E402 from miniworld.vae.codec import StreamingVAEDecoder, vae_encode # noqa: E402 from miniworld.vae.wan22_vae import Wan2_2_VAE # noqa: E402 # --------------------------------------------------------------------------- # # Constants (match scripts/sample_re10k.sh) # # --------------------------------------------------------------------------- # MINIWORLD_REPO = "zhaoyian01/MiniWorld" MINIWORLD_CKPT = "MiniWorld_1b_re10k.pt" VAE_REPO = "Wan-AI/Wan2.2-TI2V-5B" VAE_FILE = "Wan2.2_VAE.pth" RESIZE_H, RESIZE_W = 240, 320 SPATIAL_DOWNSAMPLE = 16 LATENT_CHANNELS = 48 POSE_ENC_FREQ = 15 DF_CHUNK_SIZE = 2 DF_ARDIFF_STEP = 5 STREAM_INFLIGHT_CHUNKS = 8 STREAM_MAX_CACHE_CHUNKS = 24 STREAM_SINK_SIZE = 1 SAMPLE_HISTORY_LEN = 1 SAVE_FPS = 8 MAX_SEED = np.iinfo(np.int32).max H_LAT, W_LAT = RESIZE_H // SPATIAL_DOWNSAMPLE, RESIZE_W // SPATIAL_DOWNSAMPLE TRAJECTORIES = [ "orbit_right", "orbit_left", "pan_right", "pan_left", "forward", "backward", "tilt_up", "tilt_down", "spiral", "zoom_in", "zoom_out", "static", ] # --------------------------------------------------------------------------- # # Model construction # # --------------------------------------------------------------------------- # print("Fetching Wan2.2 VAE ...", flush=True) vae_path = hf_hub_download(VAE_REPO, VAE_FILE) print("Fetching MiniWorld-1B (RealEstate10K) ...", flush=True) ckpt_path = hf_hub_download(MINIWORLD_REPO, MINIWORLD_CKPT) _ckpt = torch.load(ckpt_path, map_location="cpu") _meta: dict = {} _weights = None if isinstance(_ckpt, dict): # `miniworld/sample.py` expects a training checkpoint wrapper; the *released* # weights are a bare state dict of `net.*` tensors, so support both. for _key in ("ema_model", "model", "ema", "state_dict", "module"): cand = _ckpt.get(_key) if isinstance(cand, dict) and cand: _weights = cand _meta = _ckpt.get("meta") or {} print(f"[Checkpoint] using wrapped weights under {_key!r}", flush=True) break if _weights is None and any( isinstance(k, str) and k.startswith("net.") for k in _ckpt ): _weights = _ckpt print("[Checkpoint] bare state dict (no training wrapper)", flush=True) if _weights is None: raise RuntimeError( "Unrecognised MiniWorld checkpoint layout; top-level keys: " f"{list(_ckpt)[:8] if isinstance(_ckpt, dict) else type(_ckpt)}" ) def _resolve_latent_frames() -> int: for key in ("latent_frames", "trained_num_frames"): val = int(_meta.get(key, 0) or 0) if val > 0: return val freqs = _weights.get("net.feat_rope.freqs_cos") tokens_per_frame = H_LAT * W_LAT if freqs is not None and freqs.shape[0] % tokens_per_frame == 0: return int(freqs.shape[0] // tokens_per_frame) raise RuntimeError("Cannot determine the checkpoint's latent frame count") LATENT_FRAMES = _resolve_latent_frames() WM_MODEL = str(_meta.get("wm_model") or "1B") TRAINED_NUM_FRAMES = int(_meta.get("trained_num_frames", 0) or 0) or LATENT_FRAMES MAX_TOTAL_LEN = TRAINED_NUM_FRAMES print( f"[Checkpoint] wm_model={WM_MODEL} latent_frames={LATENT_FRAMES} " f"trained_num_frames={TRAINED_NUM_FRAMES}", flush=True, ) denoiser = build_denoiser_from_mode( DenoiserConfig( wm_model=WM_MODEL, latent_size=(H_LAT, W_LAT), latent_channels=LATENT_CHANNELS, latent_frames=LATENT_FRAMES, wm_mlp_ratio=4.0, wm_use_qknorm=True, wm_use_checkpoint=False, cond_dim=4 * 6 * 2 * POSE_ENC_FREQ, cond_per_token=True, adaln_mode="adaln_lora", cond_dropout_prob=0.1, timestep_baseshift=2.667, timestep_shift=-1.0, num_sampling_steps=100, cfg_scale=2.0, cfg_interval_min=0.2, cfg_interval_max=1.0, df_chunk_size=DF_CHUNK_SIZE, df_ardiff_step=DF_ARDIFF_STEP, ) ).eval() _missing, _unexpected = denoiser.load_state_dict(_weights, strict=False) if _missing or _unexpected: raise RuntimeError( f"MiniWorld checkpoint does not match the built model.\n" f"missing ({len(_missing)}): {_missing[:12]}\n" f"unexpected ({len(_unexpected)}): {_unexpected[:12]}" ) denoiser.trained_num_frames = TRAINED_NUM_FRAMES print(f"[Checkpoint] loaded: all {len(_weights)} keys matched", flush=True) del _ckpt, _weights denoiser = denoiser.to("cuda") vae = Wan2_2_VAE(vae_pth=vae_path, device="cuda") vae.model.requires_grad_(False) vae.model.eval() _COND_CFG = ConditioningConfig( use_pose_cond=True, use_action_cond=False, pose_enc_freq=POSE_ENC_FREQ ) # --------------------------------------------------------------------------- # # Helpers # # --------------------------------------------------------------------------- # def _prepare_init_frame(image) -> torch.Tensor: """PIL / ndarray -> ``(H, W, C)`` float32 in [-1, 1] (== `load_init_image`).""" if image is None: raise gr.Error("Please provide an initial frame.") if isinstance(image, np.ndarray): image = Image.fromarray(image) arr = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0 img = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0) if tuple(img.shape[-2:]) != (RESIZE_H, RESIZE_W): img = torch.nn.functional.interpolate( img, size=(RESIZE_H, RESIZE_W), mode="bilinear", align_corners=False ) return img.squeeze(0).permute(1, 2, 0).contiguous() * 2.0 - 1.0 def _write_mp4(frames: np.ndarray, fps: int) -> str: import imageio.v2 as imageio path = os.path.join(tempfile.mkdtemp(), "miniworld.mp4") writer = imageio.get_writer( path, fps=fps, codec="libx264", quality=8, macro_block_size=1, ffmpeg_params=["-pix_fmt", "yuv420p"], ) try: for frame in frames: writer.append_data(frame) finally: writer.close() return path def _rollout_tflops(total_len: int, steps: int) -> float: """Replay the streaming schedule to cost a rollout in DiT TFLOPs. The AR-diffusion schedule is not linear in ``total_len`` (short rollouts that fit inside the in-flight window run the *full* sampler), so the ZeroGPU reservation is derived from the same bookkeeping the sampler does. """ chunk = DF_CHUNK_SIZE ar = DF_ARDIFF_STEP inflight = STREAM_INFLIGHT_CHUNKS max_cache_frames = STREAM_MAX_CACHE_CHUNKS * chunk total_chunks = (total_len + chunk - 1) // chunk eff = steps if total_chunks <= inflight else min(steps, inflight * ar) prev = [0] * total_chunks masks = [] n_rows = 0 while any(p != eff for p in prev): row = [0] * total_chunks for i in range(total_chunks): row[i] = prev[i] + 1 if (i == 0 or prev[i - 1] == eff) else row[i - 1] - ar row[i] = max(0, min(eff, row[i])) masks.append([row[i] != prev[i] for i in range(total_chunks)]) prev = row n_rows += 1 if n_rows > 4000: # safety valve break terminal = min(inflight, total_chunks) committed = 0 cache_frames = 0 tflops = 0.0 # per-forward TFLOPs for a 1B DiT: 0.6 per query frame (linear layers) plus # 0.01548 per (query frame x key frame) (attention), at 300 tokens/frame. for step in range(n_rows): if terminal < total_chunks and masks[step][terminal]: terminal += 1 win_sc = max(0, terminal - inflight) while committed < win_sc: frames = min((committed + 1) * chunk, total_len) - committed * chunk tflops += 2 * frames * (0.6 + 0.01548 * (cache_frames + frames)) cache_frames = min(cache_frames + frames, max_cache_frames) committed += 1 if terminal <= win_sc: continue q_frames = min(terminal * chunk, total_len) - win_sc * chunk tflops += 2 * q_frames * (0.6 + 0.01548 * (cache_frames + q_frames)) return tflops # Calibrated on this Space's ZeroGPU H200 slice: measured 46.0s / 72.3s / 157.2s # at total_len 20 / 32 / 64 against 2395 / 3599 / 7703 modelled TFLOP, i.e. a # very clean 47.8 TFLOP/s (streaming VAE decode overlaps the denoiser, so it # needs no separate term). _TFLOPS_PER_SEC = 47.8 _VAE_SEC_PER_LATENT_FRAME = 0.0 _FIXED_OVERHEAD_SEC = 2.0 def _duration(*args, **kwargs) -> int: total_len, steps = 32, 100 if len(args) >= 4: total_len = int(args[3]) if len(args) >= 8: steps = int(args[7]) total_len = int(kwargs.get("total_len", total_len)) steps = int(kwargs.get("num_sampling_steps", steps)) total_len = max(4, min(total_len, 64)) est = ( _FIXED_OVERHEAD_SEC + _rollout_tflops(total_len, steps) / _TFLOPS_PER_SEC + _VAE_SEC_PER_LATENT_FRAME * total_len ) return int(min(400, math.ceil(est * 1.15))) # --------------------------------------------------------------------------- # # Inference # # --------------------------------------------------------------------------- # @spaces.GPU(duration=_duration) @torch.no_grad() def simulate( image, trajectory: str = "orbit_right", magnitude: float = 3.0, total_len: int = 32, seed: int = 0, randomize_seed: bool = True, cfg_scale: float = 2.0, num_sampling_steps: int = 100, focal_norm: float = 0.5, progress=gr.Progress(track_tqdm=True), ): init_frame = _prepare_init_frame(image) total_len = max(4, min(int(total_len), MAX_TOTAL_LEN)) if randomize_seed: seed = int(np.random.randint(0, MAX_SEED)) seed = int(seed) % (MAX_SEED + 1) device = torch.device("cuda") denoiser.cfg_scale = float(cfg_scale) denoiser.steps = int(num_sampling_steps) # `build_custom_trajectory` spreads the whole path evenly over the rollout, # so a fixed magnitude means *faster* per-frame motion in a shorter clip. # The authors' guidance is to scale it linearly with length to keep the # apparent speed constant (3.0 @ total_len 64 -> 4.5 @ 96), so the slider is # exposed as a speed in "magnitude at 64 latent frames" units. magnitude_eff = float(magnitude) * total_len / 64.0 videos = init_frame.unsqueeze(0).unsqueeze(0).to(device) # (1, 1, H, W, C) poses = ( build_custom_trajectory( trajectory, num_frames=4 * (total_len - 1) + 1, focal_norm=float(focal_norm), magnitude=magnitude_eff, ) .unsqueeze(0) .to(device) ) generator = torch.Generator(device="cpu").manual_seed(seed) noise = torch.randn( 1, LATENT_CHANNELS, total_len, H_LAT, W_LAT, generator=generator, dtype=torch.float32, ).to(device) start = time.perf_counter() with torch.autocast(device_type="cuda", dtype=torch.bfloat16, enabled=True): latents = vae_encode( vae, rearrange(videos, "b t h w c -> b c t h w").contiguous() ) _, c_latent, _, h_lat, w_lat = latents.shape full_latents = latents.new_zeros(1, c_latent, total_len, h_lat, w_lat) full_latents[:, :, :1] = latents[:, :, :1] cond_seq = build_cond_seq_for_batch( cfg=_COND_CFG, poses=poses, actions=None, t_latent=total_len, h_lat=h_lat, w_lat=w_lat, ) _, pred_rgb = denoiser.generate_eval_latents_streaming( full_latents, cond_seq, total_len=total_len, history_len=SAMPLE_HISTORY_LEN, max_cache_chunks=STREAM_MAX_CACHE_CHUNKS, inflight_chunks=STREAM_INFLIGHT_CHUNKS, sink_frames=STREAM_SINK_SIZE, stream_decoder=StreamingVAEDecoder(vae), noise=noise.to(full_latents.dtype), ) elapsed = time.perf_counter() - start video = ((pred_rgb[0].permute(1, 2, 3, 0).clamp(-1, 1) + 1.0) * 127.5).to( torch.uint8 ) frames = video.cpu().numpy() path = _write_mp4(frames, SAVE_FPS) n = int(frames.shape[0]) print(f"[Timing] total_len={total_len} steps={num_sampling_steps}: " f"{elapsed:.2f}s (reserved {_duration(None, trajectory, magnitude, total_len, seed, False, cfg_scale, num_sampling_steps)}s)", flush=True) return ( path, seed, f"**{n} frames** @ {SAVE_FPS} fps ({n / SAVE_FPS:.1f}s) · " f"{total_len} latent frames · `{trajectory}` · speed {magnitude:g} " f"(magnitude {magnitude_eff:.2f}) · " f"seed `{seed}` · {elapsed:.1f}s of GPU time", ) # --------------------------------------------------------------------------- # # UI # # --------------------------------------------------------------------------- # CSS = "#col-container { max-width: 1060px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); }" with gr.Blocks() as demo: with gr.Column(elem_id="col-container"): gr.Markdown( f""" # 🌍 MiniWorld · camera-controlled world model Hand MiniWorld-1B **one frame and a camera path** and it rolls the world forward autoregressively — no text prompt, no reference video, no ground-truth poses. A position-bounded streaming KV cache plus causal Wan2.2 VAE decoding keep the horizon open, so a rollout can run to {4 * (MAX_TOTAL_LEN - 1) + 1} frames from a {TRAINED_NUM_FRAMES}-latent-frame checkpoint. Model: [`zhaoyian01/MiniWorld`](https://huggingface.co/zhaoyian01/MiniWorld) (RealEstate10K, {WM_MODEL}) · Paper: [MiniWorld: Democratizing the Training of Video World Models from Scratch](https://huggingface.co/papers/2608.01127) · Code: [zhao-yian/MiniWorld](https://github.com/zhao-yian/MiniWorld) """ ) with gr.Row(): with gr.Column(): image = gr.Image( label="Initial frame", type="pil", height=270, sources=["upload", "clipboard"], ) trajectory = gr.Dropdown( label="Camera trajectory", choices=TRAJECTORIES, value="orbit_right", ) magnitude = gr.Slider( label="Camera speed", minimum=0.5, maximum=8.0, step=0.5, value=3.0, info="3.0 is the paper's default: clear, stable motion. " "1.0 is nearly static, 8.0 breaks down late. Scaled " "internally with rollout length so the apparent speed " "stays constant.", ) total_len = gr.Slider( label="Rollout length (latent frames)", minimum=20, maximum=MAX_TOTAL_LEN, step=4, value=32, info=f"Each latent frame decodes to 4 RGB frames at {SAVE_FPS} fps; " f"{MAX_TOTAL_LEN} → {4 * (MAX_TOTAL_LEN - 1) + 1} frames.", ) run_button = gr.Button("Simulate", variant="primary") with gr.Column(): result = gr.Video( label="Rollout", autoplay=True, loop=True, height=270 ) info = gr.Markdown() with gr.Accordion("Advanced settings", open=False): with gr.Row(): seed = gr.Slider( label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0 ) randomize_seed = gr.Checkbox(label="Randomize seed", value=True) with gr.Row(): cfg_scale = gr.Slider( label="Guidance scale (CFG)", minimum=1.0, maximum=5.0, step=0.1, value=2.0, ) num_sampling_steps = gr.Slider( label="Sampling steps", minimum=20, maximum=100, step=10, value=100, info="Effective steps per chunk are capped by the streaming " "schedule at in-flight chunks × AR step = 40.", ) focal_norm = gr.Slider( label="Normalized focal length", minimum=0.3, maximum=1.2, step=0.05, value=0.5, info="0.5 matches typical RealEstate10K intrinsics; smaller = wider FOV.", ) gr.Examples( examples=[ ["examples/kitchen.png", "orbit_right", 3.0, 32], ["examples/deck.png", "forward", 3.0, 32], ["examples/garden.png", "pan_left", 3.0, 32], ], inputs=[image, trajectory, magnitude, total_len], outputs=[result, seed, info], fn=simulate, cache_examples=True, cache_mode="lazy", ) gr.on( triggers=[run_button.click], fn=simulate, inputs=[ image, trajectory, magnitude, total_len, seed, randomize_seed, cfg_scale, num_sampling_steps, focal_norm, ], outputs=[result, seed, info], ) demo.queue().launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)