Spaces:
Running on Zero
Running on Zero
multimodalart HF Staff
Size the ZeroGPU reservation from measured runtimes; expose the example path on the API
fee621f verified Download app.py from ForgeWM/forgewm-world-model: direct link, hf CLI and curl.
- Browser
- Download file 20.3 kB
-
https://huggingface.co/spaces/ForgeWM/forgewm-world-model/resolve/main/app.py
- Command line
-
hf download hf://spaces/ForgeWM/forgewm-world-model/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ForgeWM/forgewm-world-model/resolve/main/app.py
20.3 kB
| """ForgeWM — few-step, action-conditioned Minecraft world model. | |
| Gradio demo for `ForgeWM/ForgeWM` (paper: "ForgeWM: Progressive Causal Training | |
| for Few-Step Action-Conditioned Video World Models"). | |
| Faithful to the repo's own `inference.py` / `pipeline/causal_inference.py`: | |
| same 352x640 resolution, 3-latent-frame causal blocks, sliding window of 6 | |
| latent frames, the released `warp_denoising_step` schedules, and First-Frame | |
| Enhancement for the 1-/2-step students. | |
| """ | |
| import os | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") | |
| import spaces # noqa: E402 (must precede torch) | |
| import gc # noqa: E402 | |
| import shutil # noqa: E402 | |
| import tempfile # noqa: E402 | |
| import time # noqa: E402 | |
| from typing import List, Tuple # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| import imageio.v2 as imageio # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import torch # noqa: E402 | |
| import torch.nn.functional as F # noqa: E402 | |
| from huggingface_hub import hf_hub_download # noqa: E402 | |
| from omegaconf import OmegaConf # noqa: E402 | |
| from PIL import Image # noqa: E402 | |
| from pipeline import CausalInferencePipeline | |
| from utils.wan_wrapper import WanVAEWrapper | |
| # ────────────────────────────── constants ────────────────────────────── | |
| HEIGHT, WIDTH = 352, 640 | |
| FPS = 12 | |
| VAE_TCR = 4 # VAE temporal compression ratio | |
| FRAMES_PER_BLOCK = 3 # latent frames per causal block | |
| CAM_VALUE = 0.10 # camera delta magnitude, from inference.py | |
| MINECRAFT_ACTIONS = [ | |
| "forward", "back", "left", "right", | |
| "turn_right", "turn_left", "look_up", "look_down", | |
| "forward_turn_right", "random", "no_action", | |
| ] | |
| BASE_REPO = "Skywork/Matrix-Game-2.0" | |
| FORGEWM_REPO = "ForgeWM/ForgeWM" | |
| VARIANTS = { | |
| "ForgeWM-4 · 4 steps": ("configs/stage3_dmd.yaml", "stage3/model.pt"), | |
| "ForgeWM-2 · 2 steps": ("configs/stage3_dmd_2step.yaml", "2step/model.pt"), | |
| "ForgeWM-1 · 1 step": ("configs/stage3_dmd_1step.yaml", "1step/model.pt"), | |
| } | |
| DEFAULT_VARIANT = "ForgeWM-4 · 4 steps" | |
| # Measured on the ZeroGPU Blackwell card (12-block rollouts): 1.04 s/block for | |
| # the 4-step student, 0.40 s/block for the 1-step one, plus ~4 s of CLIP + VAE | |
| # encode/decode and mp4 muxing. Used only to size the @spaces.GPU reservation. | |
| SECONDS_PER_CHUNK = { | |
| "ForgeWM-4 · 4 steps": 1.20, | |
| "ForgeWM-2 · 2 steps": 0.75, | |
| "ForgeWM-1 · 1 step": 0.50, | |
| } | |
| FIXED_OVERHEAD = 13.0 # CLIP + VAE + mux + first-call weight streaming | |
| CKPT_DIR = os.path.join(os.getcwd(), "ckpts", "MG2-base") | |
| # ─────────────────────────── weight preparation ─────────────────────────── | |
| def _link(src: str, dst: str) -> None: | |
| if os.path.lexists(dst): | |
| os.remove(dst) | |
| os.symlink(src, dst) | |
| def _purge(path: str) -> None: | |
| """Drop a hub blob from local disk once its tensors are in memory.""" | |
| try: | |
| real = os.path.realpath(path) | |
| if os.path.isfile(real): | |
| os.remove(real) | |
| if os.path.islink(path): | |
| os.remove(path) | |
| except OSError as exc: # pragma: no cover | |
| print(f"[disk] could not purge {path}: {exc}") | |
| def _disk() -> str: | |
| total, used, free = shutil.disk_usage("/") | |
| return f"disk {used / 2**30:.1f}G used / {free / 2**30:.1f}G free" | |
| os.makedirs(os.path.join(CKPT_DIR, "xlm-roberta-large"), exist_ok=True) | |
| print(f"[setup] fetching Matrix-Game-2.0 base weights … ({_disk()})", flush=True) | |
| _dit = hf_hub_download(BASE_REPO, "base_model/diffusion_pytorch_model.safetensors") | |
| _link(_dit, os.path.join(CKPT_DIR, "diffusion_pytorch_model.safetensors")) | |
| _link(hf_hub_download(BASE_REPO, "base_model/base_config.json"), | |
| os.path.join(CKPT_DIR, "base_config.json")) | |
| _vae_path = hf_hub_download(BASE_REPO, "Wan2.1_VAE.pth") | |
| _clip_path = hf_hub_download( | |
| BASE_REPO, "models_clip_open-clip-xlm-roberta-large-vit-huge-14.pth") | |
| for _tok in ("sentencepiece.bpe.model", "special_tokens_map.json", | |
| "tokenizer.json", "tokenizer_config.json"): | |
| _link(hf_hub_download(BASE_REPO, f"xlm-roberta-large/{_tok}"), | |
| os.path.join(CKPT_DIR, "xlm-roberta-large", _tok)) | |
| print(f"[setup] base weights ready ({_disk()})", flush=True) | |
| # ─────────────────────────────── models ─────────────────────────────── | |
| torch.set_grad_enabled(False) | |
| DTYPE = torch.bfloat16 | |
| print("[setup] building VAE + CLIP …", flush=True) | |
| VAE = WanVAEWrapper( | |
| vae_path=_vae_path, | |
| clip_checkpoint_path=_clip_path, | |
| clip_tokenizer_path=os.path.join(CKPT_DIR, "xlm-roberta-large"), | |
| ).eval() | |
| VAE = VAE.to(device="cuda", dtype=DTYPE) | |
| # `WanVAEWrapper.clip` is a plain object, not a submodule, so `.to()` above does | |
| # not reach it. Move it explicitly (in fp32, as the repo does) so ZeroGPU packs | |
| # it with everything else instead of paying a 4.8 GB host copy per request. | |
| VAE.clip.model = VAE.clip.model.to("cuda") | |
| gc.collect() | |
| print(f"[setup] VAE + CLIP on GPU ({_disk()})", flush=True) | |
| def _build_variant(name: str) -> CausalInferencePipeline: | |
| cfg_path, ckpt_file = VARIANTS[name] | |
| config = OmegaConf.merge(OmegaConf.load("configs/default.yaml"), | |
| OmegaConf.load(cfg_path)) | |
| config.model_kwargs.model_name = CKPT_DIR | |
| pipe = CausalInferencePipeline(config, device=torch.device("cuda"), vae=VAE) | |
| ckpt = hf_hub_download(FORGEWM_REPO, ckpt_file) | |
| try: | |
| state = torch.load(ckpt, map_location="cpu", weights_only=True) | |
| except Exception: | |
| state = torch.load(ckpt, map_location="cpu", weights_only=False) | |
| gen_sd = state.get("generator", state.get("generator_ema", state)) \ | |
| if isinstance(state, dict) else state | |
| fixed = {k.replace("._fsdp_wrapped_module.", ".") | |
| .replace("._checkpoint_wrapped_module.", "."): v | |
| for k, v in gen_sd.items()} | |
| missing, unexpected = pipe.generator.load_state_dict(fixed, strict=False) | |
| print(f"[{name}] loaded: missing={len(missing)} unexpected={len(unexpected)}", | |
| flush=True) | |
| del state, gen_sd, fixed | |
| gc.collect() | |
| _purge(ckpt) | |
| pipe.generator = pipe.generator.to(device="cuda", dtype=DTYPE).eval() | |
| pipe.vae = VAE | |
| gc.collect() | |
| print(f"[{name}] ready ({_disk()})", flush=True) | |
| return pipe | |
| PIPELINES = {} | |
| for _name in VARIANTS: | |
| try: | |
| PIPELINES[_name] = _build_variant(_name) | |
| except Exception as exc: # keep the Space usable if one student fails | |
| print(f"[setup] FAILED to load {_name}: {exc!r}", flush=True) | |
| if not PIPELINES: | |
| raise RuntimeError("No ForgeWM student could be loaded.") | |
| if DEFAULT_VARIANT not in PIPELINES: | |
| DEFAULT_VARIANT = next(iter(PIPELINES)) | |
| # The base DiT safetensors were only needed to instantiate the architecture; | |
| # every parameter has since been overwritten by the ForgeWM checkpoints. | |
| _purge(os.path.join(CKPT_DIR, "diffusion_pytorch_model.safetensors")) | |
| print(f"[setup] {len(PIPELINES)} variant(s) ready ({_disk()})", flush=True) | |
| # ──────────────────────────── action scripting ──────────────────────────── | |
| def make_action(action_type: str, num_raw_frames: int) -> Tuple[torch.Tensor, torch.Tensor]: | |
| """Minecraft action palette (mouse_dim_in=2), verbatim from inference.py.""" | |
| mouse = torch.zeros(1, num_raw_frames, 2) | |
| keyboard = torch.zeros(1, num_raw_frames, 6) | |
| if action_type == "forward": | |
| keyboard[:, :, 0] = 1.0 | |
| elif action_type == "back": | |
| keyboard[:, :, 1] = 1.0 | |
| elif action_type == "left": | |
| keyboard[:, :, 2] = 1.0 | |
| elif action_type == "right": | |
| keyboard[:, :, 3] = 1.0 | |
| elif action_type == "turn_right": | |
| mouse[:, :, 1] = CAM_VALUE | |
| elif action_type == "turn_left": | |
| mouse[:, :, 1] = -CAM_VALUE | |
| elif action_type == "look_up": | |
| mouse[:, :, 0] = CAM_VALUE | |
| elif action_type == "look_down": | |
| mouse[:, :, 0] = -CAM_VALUE | |
| elif action_type == "forward_turn_right": | |
| keyboard[:, :, 0] = 1.0 | |
| mouse[:, :, 1] = CAM_VALUE | |
| elif action_type == "random": | |
| torch.manual_seed(42) | |
| mouse = (torch.rand(1, num_raw_frames, 2) - 0.5) * (2 * CAM_VALUE) | |
| keyboard[:, :, :4] = (torch.rand(1, num_raw_frames, 4) > 0.5).float() | |
| elif action_type == "no_action": | |
| pass | |
| else: | |
| raise gr.Error(f"Unknown action '{action_type}'.") | |
| return mouse, keyboard | |
| def _chunk_start(chunk_index: int) -> int: | |
| """First raw (pixel) frame owned by causal block `chunk_index`. | |
| Latent frame 0 maps to raw frame 0; latent frame f>=1 maps to raw frames | |
| 4f-3 … 4f. A block spans 3 latent frames, so block c covers raw frames | |
| [12c-3, 12c+9) — and [0, 9) for c == 0. | |
| """ | |
| return 0 if chunk_index == 0 else 12 * chunk_index - 3 | |
| def build_action_track(segments: List[Tuple[str, int]]) -> Tuple[torch.Tensor, torch.Tensor]: | |
| total_chunks = sum(n for _, n in segments) | |
| num_raw = _chunk_start(total_chunks) | |
| mouse = torch.zeros(1, num_raw, 2) | |
| keyboard = torch.zeros(1, num_raw, 6) | |
| chunk = 0 | |
| for action, count in segments: | |
| for _ in range(count): | |
| lo, hi = _chunk_start(chunk), _chunk_start(chunk + 1) | |
| m, k = make_action(action, hi - lo) | |
| mouse[:, lo:hi] = m | |
| keyboard[:, lo:hi] = k | |
| chunk += 1 | |
| return mouse, keyboard | |
| # ───────────────────────────── preprocessing ───────────────────────────── | |
| def load_reference_frame(image_path: str) -> torch.Tensor: | |
| """Aspect-preserving resize + centre crop to 352x640, scaled to [-1, 1].""" | |
| image = Image.open(image_path).convert("RGB") | |
| arr = torch.from_numpy(np.asarray(image)).permute(2, 0, 1)[None].float() / 255.0 | |
| _, _, h, w = arr.shape | |
| if h / w > HEIGHT / WIDTH: | |
| new_w, new_h = WIDTH, max(HEIGHT, int(round(h * WIDTH / w))) | |
| else: | |
| new_h, new_w = HEIGHT, max(WIDTH, int(round(w * HEIGHT / h))) | |
| arr = F.interpolate(arr, size=(new_h, new_w), mode="bilinear", align_corners=False) | |
| top, left = (new_h - HEIGHT) // 2, (new_w - WIDTH) // 2 | |
| arr = arr[:, :, top:top + HEIGHT, left:left + WIDTH] | |
| arr = (arr - 0.5) / 0.5 | |
| return arr.unsqueeze(0).to(device="cuda", dtype=DTYPE) # [1, 1, 3, H, W] | |
| def build_conditional_dict(pipe, pixel, num_frames, mouse_cond, keyboard_cond): | |
| """MG2-style conditioning: CLIP context + first-frame latent + mask.""" | |
| num_pixel_frames = (num_frames - 1) * VAE_TCR + 1 | |
| visual_context = pipe.vae.encode_visual_context_from_pixels(pixel).to(DTYPE) | |
| first_frame = pixel[:, 0:1] | |
| pad = torch.zeros(1, num_pixel_frames - 1, 3, pixel.shape[3], pixel.shape[4], | |
| device=pixel.device, dtype=DTYPE) | |
| padded = torch.cat([first_frame, pad], dim=1).permute(0, 2, 1, 3, 4) | |
| img_cond = pipe.vae.encode_to_latent(padded).to(DTYPE) | |
| _, _, _, h_lat, w_lat = img_cond.shape | |
| mask = torch.zeros(1, num_frames, 4, h_lat, w_lat, | |
| device=pixel.device, dtype=DTYPE) | |
| mask[:, 0:1] = 1 | |
| return { | |
| "visual_context": visual_context, | |
| "cond_concat": torch.cat([mask, img_cond], dim=2), | |
| "mouse_condition": mouse_cond.to(device="cuda", dtype=DTYPE), | |
| "keyboard_condition": keyboard_cond.to(device="cuda", dtype=DTYPE), | |
| } | |
| # ──────────────────────────────── core ──────────────────────────────── | |
| def _rollout(image_path, variant, segments, seed): | |
| if image_path is None: | |
| raise gr.Error("Please provide a reference frame.") | |
| if variant not in PIPELINES: | |
| variant = DEFAULT_VARIANT | |
| segments = [(a, int(n)) for a, n in segments if int(n) > 0] | |
| total_chunks = sum(n for _, n in segments) | |
| if total_chunks < 1: | |
| raise gr.Error("Give at least one action segment a non-zero length.") | |
| if total_chunks > 12: | |
| raise gr.Error("Keep the total rollout at 12 chunks or fewer.") | |
| pipe = PIPELINES[variant] | |
| num_frames = total_chunks * FRAMES_PER_BLOCK | |
| pixel = load_reference_frame(image_path) | |
| mouse, keyboard = build_action_track(segments) | |
| cond = build_conditional_dict(pipe, pixel, num_frames, mouse, keyboard) | |
| torch.manual_seed(int(seed)) | |
| noise = torch.randn([1, num_frames, 16, HEIGHT // 8, WIDTH // 8], | |
| device="cuda", dtype=DTYPE) | |
| start = time.time() | |
| video = pipe.inference(noise=noise, conditional_dict=cond, return_latents=False) | |
| torch.cuda.synchronize() | |
| elapsed = time.time() - start | |
| pipe.vae.model.clear_cache() | |
| frames = (video[0].permute(0, 2, 3, 1).float().cpu().numpy() * 255) | |
| frames = frames.clip(0, 255).astype(np.uint8) | |
| out_path = os.path.join(tempfile.mkdtemp(), "forgewm.mp4") | |
| writer = imageio.get_writer(out_path, fps=FPS, codec="libx264", | |
| quality=8, macro_block_size=None) | |
| for frame in frames: | |
| writer.append_data(frame) | |
| writer.close() | |
| script = " → ".join(f"`{a}` ×{n}" for a, n in segments) | |
| info = ( | |
| f"**{variant}** · {total_chunks} causal blocks · {num_frames} latent " | |
| f"frames → {len(frames)} pixel frames ({len(frames) / FPS:.1f}s @ {FPS} fps)\n\n" | |
| f"Action script: {script}\n\n" | |
| f"Rollout + VAE decode: **{elapsed:.2f}s** " | |
| f"({1000 * elapsed / total_chunks:.0f} ms per block, decode included) · seed `{int(seed)}`" | |
| ) | |
| return out_path, info | |
| def _estimate(variant, chunks) -> int: | |
| per = SECONDS_PER_CHUNK.get(variant, 1.2) | |
| return int(FIXED_OVERHEAD + per * max(1, min(int(chunks), 12))) | |
| def _duration_main(*args): | |
| variant = args[1] if len(args) > 1 else DEFAULT_VARIANT | |
| chunks = sum(int(args[i]) for i in (3, 5, 7) if len(args) > i) | |
| return _estimate(variant, chunks) | |
| def _duration_example(*args): | |
| chunks = sum(int(args[i]) for i in (2, 4, 6) if len(args) > i) | |
| return _estimate(DEFAULT_VARIANT, chunks) | |
| def generate( | |
| image: str, | |
| variant: str, | |
| action_1: str, | |
| chunks_1: int, | |
| action_2: str, | |
| chunks_2: int, | |
| action_3: str, | |
| chunks_3: int, | |
| seed: int = 0, | |
| progress=gr.Progress(track_tqdm=True), | |
| ): | |
| """Roll out an action-conditioned Minecraft video from one reference frame. | |
| Args: | |
| image: Path to the reference frame that anchors the world. | |
| variant: Which few-step ForgeWM student to sample with. | |
| action_1: Action held during the first segment of the rollout. | |
| chunks_1: Length of the first segment, in 3-latent-frame blocks (~1s each). | |
| action_2: Action held during the second segment. | |
| chunks_2: Length of the second segment, in blocks. | |
| action_3: Action held during the third segment. | |
| chunks_3: Length of the third segment, in blocks. | |
| seed: Random seed for the initial noise. | |
| Returns: | |
| An mp4 of the generated rollout and a markdown summary of the run. | |
| """ | |
| return _rollout( | |
| image, variant, | |
| [(action_1, chunks_1), (action_2, chunks_2), (action_3, chunks_3)], | |
| seed, | |
| ) | |
| def generate_example( | |
| image: str, | |
| action_1: str, | |
| chunks_1: int, | |
| action_2: str, | |
| chunks_2: int, | |
| action_3: str, | |
| chunks_3: int, | |
| progress=gr.Progress(track_tqdm=True), | |
| ): | |
| """Run a bundled example with the default student and seed 0.""" | |
| return _rollout( | |
| image, DEFAULT_VARIANT, | |
| [(action_1, chunks_1), (action_2, chunks_2), (action_3, chunks_3)], | |
| 0, | |
| ) | |
| # ───────────────────────────────── UI ───────────────────────────────── | |
| CSS = """ | |
| .gradio-container { max-width: 1200px !important; } | |
| """ | |
| with gr.Blocks(title="ForgeWM") as demo: | |
| gr.Markdown( | |
| "# 🎮 ForgeWM — few-step action-conditioned world model\n" | |
| "Give it **one Minecraft frame** and a short **action script**; the " | |
| "block-causal diffusion transformer rolls the world forward at " | |
| "**1, 2 or 4 denoising steps** per 3-frame block.\n\n" | |
| "[Paper](https://huggingface.co/papers/2608.14022) · " | |
| "[Model](https://huggingface.co/ForgeWM/ForgeWM) · " | |
| "[Code](https://github.com/asdfo123/ForgeWM) · " | |
| "[Project page](https://asdfo123.github.io/ForgeWM/)" | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| image = gr.Image(label="Reference frame", type="filepath", | |
| height=280, sources=["upload", "clipboard"]) | |
| variant = gr.Radio( | |
| choices=list(PIPELINES.keys()), | |
| value=DEFAULT_VARIANT, | |
| label="Student", | |
| info="Fewer steps = faster. 1-/2-step use First-Frame Enhancement.", | |
| ) | |
| gr.Markdown("### Action script \nEach block is 12 frames ≈ 1 second.") | |
| with gr.Row(): | |
| action_1 = gr.Dropdown(MINECRAFT_ACTIONS, value="forward", | |
| label="Segment 1", scale=2) | |
| chunks_1 = gr.Slider(0, 8, value=3, step=1, label="blocks", scale=1) | |
| with gr.Row(): | |
| action_2 = gr.Dropdown(MINECRAFT_ACTIONS, value="turn_right", | |
| label="Segment 2", scale=2) | |
| chunks_2 = gr.Slider(0, 8, value=2, step=1, label="blocks", scale=1) | |
| with gr.Row(): | |
| action_3 = gr.Dropdown(MINECRAFT_ACTIONS, value="forward", | |
| label="Segment 3", scale=2) | |
| chunks_3 = gr.Slider(0, 8, value=2, step=1, label="blocks", scale=1) | |
| with gr.Accordion("Advanced", open=False): | |
| seed = gr.Slider(0, 2**31 - 1, value=0, step=1, label="Seed") | |
| run = gr.Button("Roll out the world", variant="primary") | |
| with gr.Column(scale=1): | |
| video = gr.Video(label="Generated rollout", autoplay=True, loop=True) | |
| info = gr.Markdown() | |
| gr.Examples( | |
| examples=[ | |
| ["examples/forest.png", "forward", 3, "turn_right", 2, "forward", 2], | |
| ["examples/plains.png", "forward", 3, "look_up", 1, "forward_turn_right", 3], | |
| ["examples/cave.png", "forward", 2, "turn_left", 2, "forward", 3], | |
| ], | |
| inputs=[image, action_1, chunks_1, action_2, chunks_2, action_3, chunks_3], | |
| outputs=[video, info], | |
| fn=generate_example, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| label="Examples (reference frames from the ForgeWM repo)", | |
| ) | |
| gr.Markdown( | |
| "Rollouts are 352×640 at 12 fps. Attention uses a 6-latent-frame sliding " | |
| "window with a block-causal KV cache, exactly as the released students " | |
| "were trained. Base weights: " | |
| "[Skywork/Matrix-Game-2.0](https://huggingface.co/Skywork/Matrix-Game-2.0)." | |
| ) | |
| # Same entry point the bundled examples use: seven inputs, default student | |
| # and seed. Hidden in the UI, exposed on the API / MCP server. | |
| example_trigger = gr.Button("Run example", visible=False) | |
| example_trigger.click( | |
| fn=generate_example, | |
| inputs=[image, action_1, chunks_1, action_2, chunks_2, action_3, chunks_3], | |
| outputs=[video, info], | |
| concurrency_limit=1, | |
| api_name="generate_default", | |
| ) | |
| run.click( | |
| fn=generate, | |
| inputs=[image, variant, action_1, chunks_1, action_2, chunks_2, | |
| action_3, chunks_3, seed], | |
| outputs=[video, info], | |
| concurrency_limit=1, | |
| api_name="generate", | |
| ) | |
| demo.queue(max_size=12).launch( | |
| theme=gr.themes.Citrus(), css=CSS, mcp_server=True, show_error=True) | |