CoolFace
Apppublic

diffusers-internal-dev/abot-world-interactive

sourceHugging Faceupdated 1mo agoView on Hugging Face
0likes
app.py617 linesDownload Raw Back to root
1"""2ABot-World on Modular Diffusers — interactive action-conditioned world rollout.3gradio.Server + WebSocket live-backend edition.4 5The engine is one `pipe.stream(actions=<callable>)` loop over the Modular Diffusers6`ABotWorldStreamingBlocks` preset (huggingface/diffusers#14159): the pipeline polls the7held keys once per generated block and yields every block's decoded frames.8The serving shell (gradio.Server, WebSocket frame stream, pacing, per-session queues,9front-end) is reused from https://huggingface.co/spaces/acvlab/abot-world-interactive.10 11Given an uploaded starting image (i2v conditioning), a scene prompt, and live12WASD / IJKL controls, the model autoregressively rolls out an action-conditioned13navigable world and streams decoded frames to the browser over a WebSocket.14 15This mirrors the live backend/infrastructure of16https://huggingface.co/spaces/Overworld/waypoint-1-5 (gradio.Server for17ZeroGPU-friendly start/stop + a raw WebSocket for real-time binary JPEG frame18streaming and control input), with a cleaner custom UI and image-upload seeding.19 20Multi-user safe: every endpoint is keyed by a per-client `session_id` so21concurrent players never share seed images, frame queues, or status messages.22 23ZeroGPU quota: the incoming request's ZeroGPU proxy token (the `x-ip-token` /24`x-api-token` header injected by the HF iframe) is captured per-session and25propagated into the worker thread's gradio request context, so the GPU work is26billed against the *requesting user's* quota — not the Space owner's.27 28Upstream: https://github.com/amap-cvlab/ABot-World29Model:    https://huggingface.co/YiYiXu/ABot-World-0-5B-LF-Diffusers  (built on Wan2.2-TI2V-5B)30"""31import os32os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")33 34import spaces  # must precede torch / CUDA-touching imports35 36import io37import time38import queue39import asyncio40import struct41import tempfile42import threading43import contextvars44import uuid45from collections import deque46from dataclasses import dataclass, field47from multiprocessing import Queue as MPQueue48from pathlib import Path49from typing import Dict, Optional, Set50 51import numpy as np52import torch53from PIL import Image54 55from fastapi import UploadFile, File, WebSocket, WebSocketDisconnect56from fastapi.responses import HTMLResponse, JSONResponse, FileResponse57from gradio import Server58from gradio.context import LocalContext59 60from diffusers.modular_pipelines import ABotWorldStreamingBlocks61 62# ── Repo paths ───────────────────────────────────────────────────────────────63APP_DIR = Path(__file__).resolve().parent64 65MODEL_ID = "YiYiXu/ABot-World-0-5B-LF-Diffusers"66 67# Preset starting-world images bundled with the Space (sourced from the ABot-World68# repo). Shown in the UI as clickable thumbnails that seed the i2v rollout directly.69EXAMPLES_DIR = APP_DIR / "examples"70EXAMPLE_SEEDS = [71    {"name": "desert_valley.png", "label": "Desert valley"},72    {"name": "forest_stream.png", "label": "Forest stream"},73    {"name": "mountain_meadow.png", "label": "Mountain meadow"},74    {"name": "example.png", "label": "Sample scene"},75]76 77# ── Stream / rollout configuration ───────────────────────────────────────────78# 704x1280 is the native training resolution used by the upstream web client.79STREAM_HEIGHT = 70480STREAM_WIDTH = 128081JPEG_QUALITY = 8282MAX_BLOCKS_PER_SESSION = 512     # hard cap so a session can't run forever83SESSION_IDLE_TIMEOUT = 600       # seconds; janitor reaps abandoned sessions84GPU_DURATION = 150               # seconds per @spaces.GPU allocation (one session)85 86# ── Real-time pacing configuration ───────────────────────────────────────────87# The GPU decodes a whole block (12 frames) at once, so all of a block's88# frames become available in a burst. If we forward them to the browser the89# instant they finish, the client sees N frames clustered together followed by a90# gap while the next block generates — the fps counter averages out fine, but91# the *felt* cadence is bursty. To deliver a steady real-time stream we pace the92# frames of each block evenly across the time we expect one block to take93# (mirroring the official ABot-World web_client's block-frame spreader), and we94# smooth the per-block generation time with an EMA so a single slow/fast block95# doesn't cause a visible speed-up/slow-down. See gpu_worker_thread().96PACING_EMA_ALPHA = 0.25          # smoothing factor for per-block generation time97MIN_PACING_SLEEP = 0.004         # don't bother sleeping for sub-4ms slices98DEFAULT_BLOCK_SECONDS = 0.5      # initial per-block estimate before first measure99 100# Actions map to the 8-key one-hot the model was trained on (W A S D I J K L).101# The browser sends the currently-held key set; we translate to this dict.102KEY_ORDER = ["W", "A", "S", "D", "I", "J", "K", "L"]103 104DEFAULT_PROMPT = (105    "A realistic outdoor world scene with a navigable path, natural lighting, "106    "detailed ground texture, and stable forward motion."107)108 109# ── Build the Modular Diffusers streaming pipeline (module scope) ────────────110# On ZeroGPU `pipe.to("cuda")` at import only packs the weights; they land on the111# GPU inside the @spaces.GPU rollout.112print(f"[startup] loading {MODEL_ID} ...", flush=True)113torch.set_grad_enabled(False)114pipe = ABotWorldStreamingBlocks().init_pipeline(MODEL_ID)115pipe.load_components(dtype=torch.bfloat16)116pipe.to("cuda")117print("[startup] pipeline ready.", flush=True)118 119# Only one rollout may touch the shared pipeline at a time.120_infer_lock = threading.Lock()121 122 123def _action_from_buttons(buttons):124    """Translate a set of held key names (e.g. {'W','A'}) into the model's 8-key multi-hot action."""125    held = {k.upper() for k in (buttons or [])}126    return [int(k in held) for k in KEY_ORDER]127 128 129# ── Command types (browser -> worker) ────────────────────────────────────────130@dataclass131class ControlCommand:132    buttons: Set[str]133    prompt: str134 135 136@dataclass137class StopCommand:138    pass139 140 141# ── Per-session state ────────────────────────────────────────────────────────142# NOTE on queues: the @spaces.GPU rollout runs in a forked subprocess, so any143# object it reads must cross the fork boundary. `command_queue` is therefore a144# multiprocessing Queue (browser controls / stop reach the GPU loop through it).145# `frame_queue` / `status_queue` are plain queue.Queue used only in the parent146# process (frames arrive back via the ZeroGPU generator IPC and are forwarded147# to the WebSocket by the worker thread).148@dataclass149class GameSession:150    session_id: str151    command_queue: "MPQueue"152    frame_queue: "queue.Queue"153    status_queue: "queue.Queue"154    stop_event: threading.Event155    seed_path: str156    prompt: str157    seed: int158    worker_thread: Optional[threading.Thread] = None159    frame_times: deque = field(default_factory=lambda: deque(maxlen=30))160    last_active: float = field(default_factory=time.time)161 162    def touch(self):163        self.last_active = time.time()164 165    def stop(self):166        self.stop_event.set()167        try:168            self.command_queue.put_nowait(StopCommand())169        except Exception:170            pass171        if self.worker_thread and self.worker_thread.is_alive():172            self.worker_thread.join(timeout=4.0)173 174 175_sessions: Dict[str, GameSession] = {}176_sessions_lock = threading.Lock()177 178# Contextvar carrying the active session's status queue (inherited by the worker179# thread via contextvars.copy_context()).180_current_status_queue: "contextvars.ContextVar[Optional[queue.Queue]]" = contextvars.ContextVar(181    "abot_status_queue", default=None182)183 184 185def broadcast_status(msg: str):186    q = _current_status_queue.get()187    if q is None:188        return189    try:190        q.put_nowait(msg)191    except queue.Full:192        pass193 194 195def _get_session(session_id: str) -> Optional[GameSession]:196    with _sessions_lock:197        return _sessions.get(session_id)198 199 200def _drop_session(session_id: str) -> Optional[GameSession]:201    with _sessions_lock:202        return _sessions.pop(session_id, None)203 204 205def _reap_idle_sessions():206    while True:207        time.sleep(60)208        now = time.time()209        to_drop = []210        with _sessions_lock:211            for sid, sess in list(_sessions.items()):212                worker_dead = sess.worker_thread is None or not sess.worker_thread.is_alive()213                idle = (now - sess.last_active) > SESSION_IDLE_TIMEOUT214                if worker_dead and idle:215                    to_drop.append(sid)216            for sid in to_drop:217                _sessions.pop(sid, None)218        if to_drop:219            print(f"Janitor reaped {len(to_drop)} idle session(s)", flush=True)220 221 222threading.Thread(target=_reap_idle_sessions, daemon=True).start()223 224 225# ── GPU worker ───────────────────────────────────────────────────────────────226def gpu_worker_thread(session: "GameSession"):227    """Parent-thread driver: consumes frames yielded by the ZeroGPU generator,228    computes FPS, and forwards frames to the WebSocket via `frame_queue`.229 230    Status/stop live in the parent process; the GPU loop is steered purely231    through the (picklable, cross-fork) `command_queue`.232    """233    try:234        broadcast_status("GPU allocated — starting world…")235        gen = create_gpu_rollout_loop(236            session.command_queue, session.seed_path, session.prompt, session.seed,237        )238        first = True239        # Steady send clock: `next_send` is the monotonic time at which the next240        # frame *should* be delivered. Each frame's slot is one smoothed241        # inter-frame interval after the previous, so frames leave at a constant242        # cadence regardless of the bursty block boundaries. `block_seconds` is243        # an EMA of measured per-block generation time (frames/block ÷ that gives244        # the target inter-frame interval).245        block_seconds = DEFAULT_BLOCK_SECONDS246        next_send = None247        while not session.stop_event.is_set():248            try:249                frame, block_idx, frame_idx, frames_in_block, block_elapsed = next(gen)250            except StopIteration:251                print("Rollout generator exhausted", flush=True)252                break253            except Exception as e:254                if "aborted" in str(e).lower() or "duration" in str(e).lower():255                    print(f"GPU time expired: {e}", flush=True)256                else:257                    print(f"Worker error: {e}", flush=True)258                    broadcast_status(f"error:{e}")259                break260 261            if first:262                broadcast_status("Rolling out — use WASD / IJKL to steer.")263                first = False264 265            # Update the smoothed per-block time on the first frame of each block266            # (block_elapsed is constant across a block's frames).267            if frame_idx == 0 and block_elapsed > 0:268                block_seconds = (269                    PACING_EMA_ALPHA * block_elapsed270                    + (1.0 - PACING_EMA_ALPHA) * block_seconds271                )272            fpb = max(1, frames_in_block)273            interval = block_seconds / fpb        # target seconds between frames274 275            # ── Steady-cadence gate ──────────────────────────────────────────276            # Hold each frame until its scheduled slot so the parent emits at a277            # constant interval instead of dumping a whole block at once.278            now = time.time()279            if next_send is None:280                next_send = now281            sleep_for = next_send - now282            if sleep_for > MIN_PACING_SLEEP:283                # Wake early if a stop is requested so we stay responsive.284                if session.stop_event.wait(timeout=sleep_for):285                    break286                now = time.time()287            # Advance the schedule; if we've fallen far behind (e.g. a long GPU288            # stall), resync to now so we don't try to "catch up" in a burst.289            next_send = max(now, next_send + interval)290 291            now = time.time()292            session.frame_times.append(now)293            fps = 0.0294            if len(session.frame_times) >= 2:295                elapsed = session.frame_times[-1] - session.frame_times[0]296                fps = (len(session.frame_times) - 1) / elapsed if elapsed > 0 else 0.0297            # Keep only the freshest frame if the consumer fell behind: coalesce298            # stale frames rather than letting them queue up and flush in a burst.299            while session.frame_queue.qsize() > 1:300                try:301                    session.frame_queue.get_nowait()302                except queue.Empty:303                    break304            try:305                session.frame_queue.put_nowait((frame, block_idx, round(fps, 1)))306            except queue.Full:307                pass308    finally:309        session.stop_event.set()310        print("Worker thread finished", flush=True)311 312 313def create_gpu_rollout_loop(command_queue, seed_path, prompt_text, seed):314    """Return a ZeroGPU generator that rolls the world out block-by-block.315 316    Only picklable primitives + the multiprocessing `command_queue` cross the317    fork boundary. Live controls (held key set) and stop arrive via that queue.318    """319    @spaces.GPU(duration=GPU_DURATION)320    def gpu_rollout():321        prompt = (prompt_text or DEFAULT_PROMPT).strip() or DEFAULT_PROMPT322        image = Image.open(seed_path).convert("RGB")323        state = {"action": _action_from_buttons({"W"}), "block_start": time.time()}  # default: forward324 325        def action_source(block_index):326            """Polled by the pipeline once per block: newest held-key set wins, None stops the rollout."""327            if block_index >= MAX_BLOCKS_PER_SESSION:328                return None329            while True:330                try:331                    cmd = command_queue.get_nowait()332                except Exception:333                    break334                if isinstance(cmd, StopCommand):335                    return None336                if isinstance(cmd, ControlCommand):337                    state["action"] = _action_from_buttons(cmd.buttons)338            state["block_start"] = time.time()339            return state["action"]340 341        with _infer_lock:342            events = pipe.stream(343                prompt=prompt,344                image=image,345                height=STREAM_HEIGHT,346                width=STREAM_WIDTH,347                actions=action_source,348                generator=torch.Generator("cpu").manual_seed(int(seed)),349            )350            for event in events:351                if event.path != "denoise.rollout":352                    continue  # inner per-denoise-step events353                # Time the full generate+decode of one block so the parent thread354                # can pace this block's frames over that duration.355                block_elapsed = time.time() - state["block_start"]356                b = event.loop_kwargs["k"]357                frames = (event.state.get("frames") * 255).clip(0, 255).astype(np.uint8)358                n = len(frames)359                for i, f in enumerate(frames):360                    # (frame, block_idx, frame_idx_in_block, frames_in_block,361                    #  block_elapsed) — the pacing metadata lets the parent362                    # spread this block's frames evenly rather than bursting.363                    yield (f, b, i, n, block_elapsed)364 365    return gpu_rollout()366 367 368# ── App (gradio.Server) ──────────────────────────────────────────────────────369app = Server()370 371 372@app.api(name="start_game")373def start_game(session_id: str = "", seed_path: str = "",374               prompt: str = "", seed: int = 42) -> str:375    """Start a new interactive world rollout for `session_id`.376 377    Args:378        session_id: per-client id (UUID) isolating this player's stream.379        seed_path: filepath (uploaded via /upload) of the starting frame image380            that seeds the i2v world rollout.381        prompt: scene description.382        seed: RNG seed for reproducibility.383 384    Returns:385        The session_id actually used.386    """387    if not session_id:388        session_id = str(uuid.uuid4())389 390    prior = _drop_session(session_id)391    if prior is not None:392        prior.stop()393 394    if not seed_path:395        raise ValueError("A starting image is required — please upload one first.")396 397    command_queue = MPQueue()  # crosses the ZeroGPU fork boundary398    frame_queue: "queue.Queue" = queue.Queue(maxsize=4)399    status_queue: "queue.Queue" = queue.Queue(maxsize=32)400    stop_event = threading.Event()401 402    session = GameSession(403        session_id=session_id,404        command_queue=command_queue,405        frame_queue=frame_queue,406        status_queue=status_queue,407        stop_event=stop_event,408        seed_path=seed_path,409        prompt=prompt or DEFAULT_PROMPT,410        seed=int(seed),411    )412    with _sessions_lock:413        _sessions[session_id] = session414 415    # Capture the *incoming request* — HF has already injected this user's416    # ZeroGPU proxy token (x-ip-token / x-api-token) into its headers. We417    # re-set it into the worker thread's gradio LocalContext so that418    # @spaces.GPU bills GPU time against THIS user's quota, not the owner's.419    gradio_request = LocalContext.request.get(None)420    status_token = _current_status_queue.set(status_queue)421    try:422        broadcast_status("Requesting GPU from ZeroGPU…")423 424        def _thread_entry():425            # Re-establish the request context inside the worker thread so the426            # ZeroGPU scheduler reads the requesting user's token.427            if gradio_request is not None:428                try:429                    LocalContext.request.set(gradio_request)430                except Exception:431                    pass432            gpu_worker_thread(session)433 434        ctx = contextvars.copy_context()435        worker = threading.Thread(target=ctx.run, args=(_thread_entry,), daemon=True)436        session.worker_thread = worker437        worker.start()438    finally:439        _current_status_queue.reset(status_token)440 441    return session_id442 443 444@app.api(name="stop_game")445def stop_game(session_id: str = "") -> str:446    """Stop the active rollout for the given client."""447    if not session_id:448        return "no_session"449    session = _drop_session(session_id)450    if session is not None:451        session.stop()452    return "stopped"453 454 455@app.websocket("/ws")456async def game_ws(websocket: WebSocket, session_id: str = ""):457    """Real-time rollout WebSocket. Requires `?session_id=...` matching /start_game."""458    await websocket.accept()459    if not session_id:460        await websocket.send_json({"type": "error", "message": "missing session_id"})461        await websocket.close(code=1008)462        return463 464    loop = asyncio.get_event_loop()465 466    async def send_frames():467        session_ended_sent = False468        while True:469            session = _get_session(session_id)470 471            if session is not None:472                try:473                    status_msg = session.status_queue.get_nowait()474                    if status_msg.startswith("error:"):475                        await websocket.send_json({"type": "error", "message": status_msg[6:]})476                        break477                    await websocket.send_json({"type": "status", "message": status_msg})478                except queue.Empty:479                    pass480                except (WebSocketDisconnect, RuntimeError):481                    break482 483            if session is None:484                await asyncio.sleep(0.05)485                continue486            if session.stop_event.is_set() and session.frame_queue.empty():487                if not session_ended_sent:488                    try:489                        await websocket.send_json({"type": "session_ended"})490                    except (WebSocketDisconnect, RuntimeError):491                        break492                    session_ended_sent = True493                await asyncio.sleep(0.4)494                continue495            try:496                result = await loop.run_in_executor(497                    None, lambda s=session: s.frame_queue.get(timeout=0.1)498                )499                frame, count, fps = result500                img = Image.fromarray(frame)501                buf = io.BytesIO()502                img.save(buf, format="JPEG", quality=JPEG_QUALITY)503                jpeg_bytes = buf.getvalue()504                header = struct.pack(">II", int(count), int(fps * 10))505                await websocket.send_bytes(header + jpeg_bytes)506                session.touch()507            except queue.Empty:508                pass509            except (WebSocketDisconnect, RuntimeError):510                break511 512    async def receive_controls():513        while True:514            try:515                data = await websocket.receive_json()516                session = _get_session(session_id)517                if session is None:518                    continue519                session.touch()520                msg_type = data.get("type", "control")521                if msg_type == "control":522                    buttons = set(data.get("buttons", []))523                    prompt = data.get("prompt", session.prompt)524                    try:525                        session.command_queue.put_nowait(526                            ControlCommand(buttons=buttons, prompt=prompt)527                        )528                    except queue.Full:529                        pass530                elif msg_type == "stop":531                    session.stop()532            except WebSocketDisconnect:533                break534            except Exception:535                break536 537    try:538        await asyncio.gather(send_frames(), receive_controls())539    except WebSocketDisconnect:540        pass541 542 543@app.post("/upload_seed")544async def upload_seed(file: UploadFile = File(...)):545    """Accept a user-uploaded starting image and stash it server-side.546 547    Returns the temp filepath, which the browser then passes to /start_game as548    `seed_path` to seed the image-to-video (i2v) world rollout. Only an image is549    needed — there is no video upload.550    """551    try:552        raw = await file.read()553        img = Image.open(io.BytesIO(raw)).convert("RGB")554    except Exception:555        return JSONResponse({"error": "Could not read image file."}, status_code=400)556 557    tmp = tempfile.NamedTemporaryFile(prefix="abot_seed_", suffix=".png", delete=False)558    img.save(tmp.name, format="PNG")559    return {"seed_path": tmp.name}560 561 562def _safe_example_path(name: str) -> Optional[Path]:563    """Resolve `name` to a bundled example image, guarding against traversal."""564    if not any(name == e["name"] for e in EXAMPLE_SEEDS):565        return None566    path = (EXAMPLES_DIR / name).resolve()567    if EXAMPLES_DIR.resolve() not in path.parents or not path.is_file():568        return None569    return path570 571 572@app.get("/example_seeds")573async def example_seeds():574    """List the preset starting-world images available as clickable thumbnails."""575    return {"examples": [e for e in EXAMPLE_SEEDS if (EXAMPLES_DIR / e["name"]).is_file()]}576 577 578@app.get("/example_thumb")579async def example_thumb(name: str = ""):580    """Serve a preset starting-world image (for thumbnail display in the UI)."""581    path = _safe_example_path(name)582    if path is None:583        return JSONResponse({"error": "unknown example"}, status_code=404)584    return FileResponse(str(path), media_type="image/png")585 586 587@app.get("/example_seed")588async def example_seed(name: str = ""):589    """Seed the i2v rollout from a bundled preset image (no upload required).590 591    Copies the chosen example into a server-side temp file and returns its path,592    mirroring /upload_seed so the browser can pass it to /start_game as seed_path.593    """594    path = _safe_example_path(name)595    if path is None:596        return JSONResponse({"error": "unknown example"}, status_code=404)597    try:598        img = Image.open(path).convert("RGB")599    except Exception:600        return JSONResponse({"error": "could not read example image"}, status_code=500)601    tmp = tempfile.NamedTemporaryFile(prefix="abot_seed_", suffix=".png", delete=False)602    img.save(tmp.name, format="PNG")603    return {"seed_path": tmp.name}604 605 606@app.get("/", response_class=HTMLResponse)607async def homepage():608    html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")609    with open(html_path, "r", encoding="utf-8") as f:610        return f.read()611 612 613# Avoid ZeroGPU "no GPU function" error at boot.614spaces.GPU(lambda: None)615 616app.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)  # the SSR Node proxy does not forward the /ws upgrade617