Spaces:
Running on Zero
Running on Zero
multimodalart HF Staff
Calibrate ZeroGPU duration from measured runs; cap steps at 40
adb1853 verified | """StatePlay — State-Aware Game World Model demo (Street Fighter III). | |
| Wraps the official `stateplay` inference pipeline (Wan2.2-TI2V-5B visual expert | |
| + 0.75B state expert with joint attention) on ZeroGPU. Given a first frame, an | |
| initial game state and a 0.5s-per-slot action sequence, the model rolls out a | |
| 5-second video AND predicts the internal game state trajectory (timer, both | |
| players' HP, both players' super meters). | |
| """ | |
| import os | |
| import tempfile | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| os.environ.setdefault("MPLCONFIGDIR", tempfile.mkdtemp(prefix="mplconfig-")) | |
| import spaces # noqa: E402 (must precede torch / CUDA-touching imports) | |
| import math # noqa: E402 | |
| import time # noqa: E402 | |
| from pathlib import Path # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| import matplotlib # noqa: E402 | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import pandas as pd # noqa: E402 | |
| import torch # noqa: E402 | |
| from huggingface_hub import hf_hub_download, snapshot_download # noqa: E402 | |
| from stateplay import NEG_PROMPT, SF3_BUTTON_COLS, StatePlayPipeline # noqa: E402 | |
| from stateplay.utils.image import save_mp4 # noqa: E402 | |
| from stateplay.utils.state import STATE_COLUMNS, denormalize_state # noqa: E402 | |
| # --------------------------------------------------------------------------- # | |
| # Constants | |
| # --------------------------------------------------------------------------- # | |
| CKPT_REPO = "onepiece1999/StatePlay" | |
| WAN_REPO = "Wan-AI/Wan2.2-TI2V-5B" | |
| FPS = 20 | |
| SLOT_FRAMES = 10 # each action slot is held for 10 video frames = 0.5 s @ 20 fps | |
| HEIGHT, WIDTH = 480, 832 # native StatePlay training resolution | |
| STATE_MAX = {"timer": 99, "hp1": 160, "hp2": 160, "meter1": 104, "meter2": 96} | |
| NO_INPUT_TOKENS = {"", "-", ".", "_", "none", "noop", "idle", "neutral", "0"} | |
| # --------------------------------------------------------------------------- # | |
| # Weights: assemble the layout `StatePlayPipeline.from_pretrained` expects | |
| # --------------------------------------------------------------------------- # | |
| print("[startup] downloading weights…", flush=True) | |
| ckpt_path = hf_hub_download(CKPT_REPO, "StatePlay.safetensors") | |
| vae_path = hf_hub_download(WAN_REPO, "Wan2.2_VAE.pth") | |
| t5_path = hf_hub_download(WAN_REPO, "models_t5_umt5-xxl-enc-bf16.pth") | |
| tok_root = snapshot_download(WAN_REPO, allow_patterns=["google/umt5-xxl/*"]) | |
| BASE_DIR = Path(tempfile.mkdtemp(prefix="stateplay-base-")) | |
| ti2v_dir = BASE_DIR / "Wan-AI" / "Wan2.2-TI2V-5B" | |
| tok_parent = BASE_DIR / "Wan-AI" / "Wan2.1-T2V-1.3B" / "google" | |
| ti2v_dir.mkdir(parents=True, exist_ok=True) | |
| tok_parent.mkdir(parents=True, exist_ok=True) | |
| os.symlink(vae_path, ti2v_dir / "Wan2.2_VAE.pth") | |
| os.symlink(t5_path, ti2v_dir / "models_t5_umt5-xxl-enc-bf16.pth") | |
| os.symlink(Path(tok_root) / "google" / "umt5-xxl", tok_parent / "umt5-xxl") | |
| print("[startup] building pipeline…", flush=True) | |
| pipe = StatePlayPipeline.from_pretrained( | |
| base_model_dir=str(BASE_DIR), | |
| checkpoint_path=ckpt_path, | |
| torch_dtype=torch.bfloat16, | |
| ).to("cuda") | |
| BUTTON_COLS = list(pipe.button_cols) | |
| print(f"[startup] ready. buttons={BUTTON_COLS}", flush=True) | |
| # --------------------------------------------------------------------------- # | |
| # Action-sequence parsing → the parquet the pipeline reads | |
| # --------------------------------------------------------------------------- # | |
| def parse_action_slots(actions_text: str, n_slots: int) -> list[list[str]]: | |
| """Parse the slot notation into `n_slots` lists of pressed button names. | |
| Slots are separated by `|`, `,`, `;` or newlines; buttons inside a slot are | |
| joined with `+`. `-` (or an empty slot) means "no input". Extra slots are | |
| dropped, missing slots are padded with no-input. | |
| """ | |
| raw = str(actions_text or "").replace("\n", "|").replace(",", "|").replace(";", "|") | |
| chunks = raw.split("|") | |
| valid = {b.upper() for b in BUTTON_COLS} | |
| slots: list[list[str]] = [] | |
| for chunk in chunks: | |
| chunk = chunk.strip() | |
| if chunk.lower() in NO_INPUT_TOKENS: | |
| slots.append([]) | |
| continue | |
| pressed, unknown = [], [] | |
| for tok in chunk.replace(" ", "+").split("+"): | |
| tok = tok.strip().upper() | |
| if not tok: | |
| continue | |
| if tok in valid: | |
| if tok not in pressed: | |
| pressed.append(tok) | |
| else: | |
| unknown.append(tok) | |
| if unknown: | |
| raise gr.Error( | |
| f"Unknown button(s) {unknown} in slot {len(slots) + 1}. " | |
| f"Valid buttons: {', '.join(BUTTON_COLS)} (or '-' for no input)." | |
| ) | |
| slots.append(pressed) | |
| if len(slots) > n_slots: | |
| slots = slots[:n_slots] | |
| while len(slots) < n_slots: | |
| slots.append([]) | |
| return slots | |
| def build_action_parquet(slots, n_rows: int, state: dict, path: str) -> None: | |
| """Write the action/state parquet the StatePlay pipeline consumes. | |
| Buttons are written on every 10th row only (matching the dataset's 10 Hz | |
| controller log); the pipeline's hold-window densification fills the rest. | |
| Game-state columns are constant — only the first entry is used as the | |
| conditioning state, the rest of the trajectory is what the model predicts. | |
| """ | |
| data = {"frame_id": np.arange(n_rows, dtype=np.int64)} | |
| for name, value in state.items(): | |
| data[name] = np.full(n_rows, float(value), dtype=np.float32) | |
| for col in BUTTON_COLS: | |
| data[col] = np.zeros(n_rows, dtype=np.float32) | |
| df = pd.DataFrame(data) | |
| for i, pressed in enumerate(slots): | |
| row = i * SLOT_FRAMES | |
| if row >= n_rows: | |
| break | |
| for col in pressed: | |
| df.loc[row, col] = 1.0 | |
| df.to_parquet(path, index=False) | |
| def slot_layout(num_frames: int) -> tuple[int, int]: | |
| """(number of controller rows, number of 0.5s action slots) for a clip.""" | |
| n_rows = max(SLOT_FRAMES, int(num_frames) - 1) | |
| return n_rows, math.ceil(n_rows / SLOT_FRAMES) | |
| # --------------------------------------------------------------------------- # | |
| # State-trajectory visualisation | |
| # --------------------------------------------------------------------------- # | |
| def state_table(state: torch.Tensor) -> list[list[float]]: | |
| """[1, F, 5] normalized state → rows of [time_s, timer, hp1, hp2, meter1, meter2].""" | |
| values = denormalize_state(state.detach().float().cpu()).squeeze(0).numpy() | |
| rows = [] | |
| for i, row in enumerate(values): | |
| rows.append([round(i * 4.0 / FPS, 2)] + [round(float(v), 1) for v in row]) | |
| return rows | |
| def state_chart(rows: list[list[float]]) -> str: | |
| """Render the predicted state trajectory to a PNG and return its path.""" | |
| arr = np.asarray(rows, dtype=np.float32) | |
| t = arr[:, 0] | |
| fig, axes = plt.subplots(3, 1, figsize=(7.2, 6.4), sharex=True) | |
| fig.suptitle("Predicted game-state trajectory", fontsize=13) | |
| axes[0].plot(t, arr[:, 2], color="#2b7bba", lw=2, marker="o", ms=3, label="P1 HP") | |
| axes[0].plot(t, arr[:, 3], color="#d1495b", lw=2, marker="o", ms=3, label="P2 HP") | |
| axes[0].set_ylim(-5, STATE_MAX["hp1"] + 5) | |
| axes[0].set_ylabel("health") | |
| axes[1].plot(t, arr[:, 4], color="#3f8f5b", lw=2, marker="o", ms=3, label="P1 meter") | |
| axes[1].plot(t, arr[:, 5], color="#e0a419", lw=2, marker="o", ms=3, label="P2 meter") | |
| axes[1].set_ylim(-5, max(STATE_MAX["meter1"], STATE_MAX["meter2"]) + 5) | |
| axes[1].set_ylabel("super meter") | |
| axes[2].plot(t, arr[:, 1], color="#6a4c93", lw=2, marker="o", ms=3, label="timer") | |
| axes[2].set_ylim(-2, STATE_MAX["timer"] + 2) | |
| axes[2].set_ylabel("round timer") | |
| axes[2].set_xlabel("time (s)") | |
| for ax in axes: | |
| ax.grid(alpha=0.25) | |
| ax.legend(loc="upper right", fontsize=8) | |
| fig.tight_layout() | |
| out = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| fig.savefig(out.name, dpi=110) | |
| plt.close(fig) | |
| return out.name | |
| # --------------------------------------------------------------------------- # | |
| # Inference | |
| # --------------------------------------------------------------------------- # | |
| def _estimate_duration( | |
| image=None, | |
| actions_text: str = "", | |
| prompt: str = "", | |
| timer: int = 45, | |
| hp1: int = 160, | |
| hp2: int = 160, | |
| meter1: int = 0, | |
| meter2: int = 0, | |
| num_frames: int = 101, | |
| num_inference_steps: int = 30, | |
| cfg_scale: float = 5.0, | |
| action_cfg_scale: float = 1.0, | |
| state_cfg_scale: float = 1.0, | |
| seed: int = 2, | |
| *args, | |
| **kwargs, | |
| ): | |
| """ZeroGPU duration estimate: fixed overhead + per-DiT-pass cost x number of passes. | |
| Calibrated on zero-a10g (Blackwell, bf16, SDPA) against two measured runs: | |
| 41 frames / 10 steps / cfg 5.0 -> 14.4 s (latent_t 11, 2 passes) | |
| 101 frames / 30 steps / cfg 5.0 -> 94.6 s (latent_t 26, 2 passes) | |
| Both fit t = 1.2 + 0.0599 * latent_t * passes * steps within 4%, i.e. cost is | |
| essentially linear in latent length. The coefficients below add ~25-30% margin. | |
| """ | |
| latent_t = (int(num_frames) - 1) // 4 + 1 | |
| passes = 1 | |
| if float(cfg_scale) != 1.0: | |
| passes += 1 | |
| if float(action_cfg_scale) != 1.0: | |
| passes += 1 | |
| if float(state_cfg_scale) != 1.0 and float(cfg_scale) == 1.0: | |
| passes += 1 | |
| total = 8.0 + 0.075 * latent_t * passes * int(num_inference_steps) | |
| return int(min(260, math.ceil(total))) | |
| def generate( | |
| image, | |
| actions_text: str, | |
| prompt: str, | |
| timer: int = 45, | |
| hp1: int = 160, | |
| hp2: int = 160, | |
| meter1: int = 0, | |
| meter2: int = 0, | |
| num_frames: int = 101, | |
| num_inference_steps: int = 30, | |
| cfg_scale: float = 5.0, | |
| action_cfg_scale: float = 1.0, | |
| state_cfg_scale: float = 1.0, | |
| seed: int = 2, | |
| progress=gr.Progress(track_tqdm=True), | |
| ): | |
| """Roll out a Street Fighter III clip and its internal game state with StatePlay. | |
| Args: | |
| image: first frame of the rollout (any aspect; cropped to 832x480). | |
| actions_text: controller inputs, one 0.5s slot per `|`-separated entry, | |
| buttons joined with `+` (e.g. "D | UP+RIGHT | - | A"). Valid buttons: | |
| UP, DOWN, LEFT, RIGHT, Y, X, Z, A, B, C, D. | |
| prompt: NPC behaviour / strategy description used as text conditioning. | |
| timer: initial round timer (0-99). | |
| hp1: initial player-1 health (0-160). | |
| hp2: initial player-2 health (0-160). | |
| meter1: initial player-1 super meter (0-104). | |
| meter2: initial player-2 super meter (0-96). | |
| num_frames: rollout length in frames at 20 fps. | |
| num_inference_steps: flow-matching denoising steps. | |
| cfg_scale: text classifier-free-guidance scale. | |
| action_cfg_scale: action classifier-free-guidance scale (1.0 = off). | |
| state_cfg_scale: state classifier-free-guidance scale (1.0 = off). | |
| seed: RNG seed. | |
| Returns: | |
| The generated MP4, a chart of the predicted state trajectory, a table of | |
| the predicted state values, and a run-info string. | |
| """ | |
| if image is None: | |
| raise gr.Error("Please provide a first frame.") | |
| num_frames = int(num_frames) | |
| n_rows, n_slots = slot_layout(num_frames) | |
| slots = parse_action_slots(actions_text, n_slots) | |
| state = { | |
| "timer": max(0, min(int(timer), STATE_MAX["timer"])), | |
| "hp1": max(0, min(int(hp1), STATE_MAX["hp1"])), | |
| "hp2": max(0, min(int(hp2), STATE_MAX["hp2"])), | |
| "meter1": max(0, min(int(meter1), STATE_MAX["meter1"])), | |
| "meter2": max(0, min(int(meter2), STATE_MAX["meter2"])), | |
| } | |
| parquet_path = tempfile.NamedTemporaryFile(suffix=".parquet", delete=False).name | |
| build_action_parquet(slots, n_rows, state, parquet_path) | |
| started = time.perf_counter() | |
| out = pipe( | |
| image=image, | |
| actions_parquet=parquet_path, | |
| state_parquet=parquet_path, | |
| state_sampling="end", | |
| prompt=(prompt or "SF3 Game.").strip(), | |
| negative_prompt=NEG_PROMPT, | |
| num_frames=num_frames, | |
| num_inference_steps=int(num_inference_steps), | |
| cfg_scale=float(cfg_scale), | |
| state_cfg_scale=float(state_cfg_scale), | |
| action_cfg_scale=float(action_cfg_scale), | |
| height=HEIGHT, | |
| width=WIDTH, | |
| seed=int(seed), | |
| ) | |
| elapsed = time.perf_counter() - started | |
| frames = out.frames[0] | |
| video_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name | |
| save_mp4(frames, video_path, fps=FPS) | |
| rows = state_table(out.state) | |
| chart_path = state_chart(rows) | |
| pressed = " | ".join("+".join(s) if s else "-" for s in slots) | |
| info = ( | |
| f"{len(frames)} frames @ {FPS} fps ({len(frames) / FPS:.2f}s), {WIDTH}x{HEIGHT}, " | |
| f"{int(num_inference_steps)} steps, seed {int(seed)} — inference {elapsed:.1f}s\n" | |
| f"actions used ({n_slots} x 0.5s slots): {pressed}" | |
| ) | |
| try: | |
| os.remove(parquet_path) | |
| except OSError: | |
| pass | |
| return video_path, chart_path, rows, info | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| EXAMPLES = [ | |
| [ | |
| "examples/01_macro_success.png", | |
| "D | UP+RIGHT | D | UP+RIGHT | UP+LEFT | A | DOWN | C | UP+LEFT | DOWN", | |
| "NPC: Active_Behavior(Kazegiri: A rising high kick used primarily to intercept aerial opponents.), Passive_Behavior(Standing Block: Mitigates damage from high and mid-level attacks while standing.; Idle: A neutral stationary stance where no action is taken.), Strategy(Passive Guarding: Remains stationary while utilizing standing or crouching blocks to mitigate incoming damage.)", | |
| 45, 35, 111, 104, 96, | |
| ], | |
| [ | |
| "examples/02_macro_success.png", | |
| "D | D | UP+LEFT | - | A | A | DOWN | UP+RIGHT | Z | LEFT", | |
| "NPC: Active_Behavior(Walk Left: Moves horizontally to the left along the ground.; Jump Backward: Leaps away from the opponent to create distance.), Passive_Behavior(Take Hit: Sustains damage from an opponent's attack.), Strategy(Spacing Control: Maintains an optimal distance from the opponent through movement.)", | |
| 62, 70, 100, 104, 76, | |
| ], | |
| [ | |
| "examples/03_result_win.png", | |
| "D | D | D | Y | RIGHT | X | D | D | D | D", | |
| "NPC: Active_Behavior(N/A), Passive_Behavior(Take Hit: Sustains damage from an opponent's attack.; Idle: A neutral stationary stance where no action is taken.), Strategy(Defeated: Health has been depleted and the round is lost.)", | |
| 56, 85, 2, 104, 76, | |
| ], | |
| [ | |
| "examples/04_result_win.png", | |
| "Z | LEFT | A | UP+LEFT | UP | DOWN | D | Z | - | UP", | |
| "NPC: Active_Behavior(N/A), Passive_Behavior(Take Hit: Sustains damage from an opponent's attack.), Strategy(Defeated: Health has been depleted and the round is lost.)", | |
| 35, 11, 13, 76, 96, | |
| ], | |
| [ | |
| "examples/05_result_lose.png", | |
| "D | D | RIGHT | RIGHT | LEFT | D | Z | - | B | Y", | |
| "NPC: Active_Behavior(Throw: A close-range grab that bypasses blocking.; Standing Attack: A basic attack performed from a standing position.), Passive_Behavior(Idle: A neutral stationary stance where no action is taken.), Strategy(Aggressive Pressure: Continuously attacks to force the opponent into a defensive state.)", | |
| 35, 6, 88, 31, 96, | |
| ], | |
| [ | |
| "examples/06_result_lose.png", | |
| "A | RIGHT | UP+RIGHT | B | DOWN | Z | UP+RIGHT | - | Y | LEFT", | |
| "NPC: Active_Behavior(N/A), Passive_Behavior(Idle: A neutral stationary stance where no action is taken.), Strategy(Victorious: The opponent's health has been depleted and the round is won.)", | |
| 42, 12, 75, 14, 96, | |
| ], | |
| [ | |
| "examples/07_normal.png", | |
| "DOWN | C | B | Z | LEFT | LEFT | UP | A | UP | B", | |
| "NPC: Active_Behavior(Crouch: Lowers stance to the ground to duck under high attacks.; Walk Right: Moves horizontally to the right along the ground.), Passive_Behavior(Idle: A neutral stationary stance where no action is taken.), Strategy(Spacing Control: Maintains an optimal distance from the opponent through movement.)", | |
| 95, 160, 160, 15, 0, | |
| ], | |
| [ | |
| "examples/08_normal.png", | |
| "UP | UP+LEFT | RIGHT | X | C | B | A | DOWN | - | RIGHT", | |
| "NPC: Active_Behavior(N/A), Passive_Behavior(Idle: A neutral stationary stance where no action is taken.), Strategy(Neutral Game: Observes the opponent while maintaining a safe position.)", | |
| 90, 160, 152, 18, 0, | |
| ], | |
| ] | |
| HEAD = """ | |
| # 🎮 StatePlay — State-Aware Game World Model | |
| Roll out **Street Fighter III** gameplay from a single frame *and* read out the | |
| game state the model believes it is producing — health, super meters and the | |
| round timer are predicted jointly with the pixels, so the video stays consistent | |
| with the game's mechanics. | |
| [paper](https://huggingface.co/papers/2607.26754) · [project page](https://jimntu.github.io/stateplay_page/) · [code](https://github.com/Jimntu/StatePlay) · [model](https://huggingface.co/onepiece1999/StatePlay) | |
| """ | |
| ACTION_HELP = f""" | |
| **Action sequence** — one slot per `|`, each slot held for **0.5 s** (10 frames @ 20 fps). | |
| Press several buttons at once with `+`, use `-` for no input. | |
| Buttons: `UP` `DOWN` `LEFT` `RIGHT` (stick) · `{'` `'.join(SF3_BUTTON_COLS[4:])}` (attack / special channels). | |
| A 101-frame rollout uses the first **10** slots. | |
| """ | |
| CSS = """ | |
| #col-container { max-width: 1200px; margin: 0 auto; } | |
| .dark .gradio-container { color: var(--body-text-color); } | |
| """ | |
| with gr.Blocks(title="StatePlay") as demo: | |
| with gr.Column(elem_id="col-container"): | |
| gr.Markdown(HEAD) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| image = gr.Image(label="First frame", type="pil", height=260) | |
| actions = gr.Textbox( | |
| label="Action sequence", | |
| value="D | UP+RIGHT | D | UP+RIGHT | UP+LEFT | A | DOWN | C | UP+LEFT | DOWN", | |
| lines=2, | |
| ) | |
| gr.Markdown(ACTION_HELP) | |
| prompt = gr.Textbox( | |
| label="Prompt (NPC behaviour / strategy)", | |
| value="SF3 Game.", | |
| lines=3, | |
| ) | |
| gr.Markdown("**Initial game state** (conditioning — the model predicts the rest)") | |
| with gr.Row(): | |
| timer = gr.Slider(0, 99, value=45, step=1, label="Timer") | |
| hp1 = gr.Slider(0, 160, value=160, step=1, label="P1 HP") | |
| hp2 = gr.Slider(0, 160, value=160, step=1, label="P2 HP") | |
| with gr.Row(): | |
| meter1 = gr.Slider(0, 104, value=0, step=1, label="P1 meter") | |
| meter2 = gr.Slider(0, 96, value=0, step=1, label="P2 meter") | |
| run = gr.Button("Generate rollout", variant="primary") | |
| with gr.Accordion("Advanced settings", open=False): | |
| num_frames = gr.Dropdown( | |
| [41, 61, 81, 101], value=101, label="Frames (20 fps)" | |
| ) | |
| steps = gr.Slider(10, 40, value=30, step=1, label="Denoising steps") | |
| cfg = gr.Slider(1.0, 10.0, value=5.0, step=0.1, label="Text CFG") | |
| action_cfg = gr.Slider( | |
| 1.0, 5.0, value=1.0, step=0.1, label="Action CFG (1.0 = off)" | |
| ) | |
| state_cfg = gr.Slider( | |
| 1.0, 10.0, value=1.0, step=0.1, label="State CFG (1.0 = off)" | |
| ) | |
| seed = gr.Number(value=2, precision=0, label="Seed") | |
| with gr.Column(scale=1): | |
| video = gr.Video(label="Generated rollout", autoplay=True, height=300) | |
| chart = gr.Image(label="Predicted state trajectory", height=420) | |
| info = gr.Textbox(label="Run info", lines=3) | |
| with gr.Accordion("Predicted state values", open=False): | |
| table = gr.Dataframe( | |
| headers=["time_s", *STATE_COLUMNS], | |
| datatype=["number"] * 6, | |
| label="one row per latent frame (0.2 s)", | |
| ) | |
| inputs = [ | |
| image, actions, prompt, timer, hp1, hp2, meter1, meter2, | |
| num_frames, steps, cfg, action_cfg, state_cfg, seed, | |
| ] | |
| outputs = [video, chart, table, info] | |
| run.click(generate, inputs=inputs, outputs=outputs, api_name="generate") | |
| gr.Examples( | |
| examples=EXAMPLES, | |
| inputs=[image, actions, prompt, timer, hp1, hp2, meter1, meter2], | |
| outputs=outputs, | |
| fn=generate, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| label="Held-out StatePlay clips (first frame, real controller log, real initial state)", | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True) | |