Spaces:
Running on Zero
Running on Zero
Live front-end: gradio.Server + WebSocket paced frame stream (shell from acvlab/abot-world-interactive), engine = pipe.stream(action_source=...)
Browse files- README.md +9 -5
- app.py +582 -97
- index.html +634 -0
- requirements.txt +1 -0
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: 🕹️
|
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
@@ -12,8 +12,12 @@ short_description: Drive a live world model with Modular Diffusers
|
|
| 12 |
---
|
| 13 |
|
| 14 |
Interactive [ABot-World](https://huggingface.co/YiYiXu/ABot-World-0-5B-LF-Diffusers) (a real-time,
|
| 15 |
-
action-conditioned world model, Wan2.2-TI2V-5B) running natively on Modular Diffusers
|
| 16 |
-
`pipe.stream(action_source=...)`
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.25.0
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
Interactive [ABot-World](https://huggingface.co/YiYiXu/ABot-World-0-5B-LF-Diffusers) (a real-time,
|
| 15 |
+
action-conditioned world model, Wan2.2-TI2V-5B) running natively on Modular Diffusers. The engine is one
|
| 16 |
+
`pipe.stream(action_source=...)` loop over the `ABotWorldStreamingBlocks` preset
|
| 17 |
+
([huggingface/diffusers#14159](https://github.com/huggingface/diffusers/pull/14159)): the pipeline polls the
|
| 18 |
+
held keys once per generated block and yields every block's decoded frames.
|
| 19 |
|
| 20 |
+
The serving shell — `gradio.Server` start/stop endpoints, a WebSocket that streams binary JPEG frames with
|
| 21 |
+
steady pacing, per-session control queues that cross the ZeroGPU fork, and the front-end — is reused from
|
| 22 |
+
[acvlab/abot-world-interactive](https://huggingface.co/spaces/acvlab/abot-world-interactive); only the
|
| 23 |
+
~40-line GPU rollout body is ours.
|
app.py
CHANGED
|
@@ -1,131 +1,616 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import queue
|
|
|
|
|
|
|
|
|
|
| 5 |
import threading
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
import gradio as gr
|
| 8 |
import numpy as np
|
| 9 |
import torch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from diffusers.modular_pipelines import ABotWorldStreamingBlocks
|
| 12 |
-
from diffusers.utils import load_image
|
| 13 |
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
DEFAULT_PROMPT = (
|
| 22 |
"A realistic outdoor world scene with a navigable path, natural lighting, "
|
| 23 |
"detailed ground texture, and stable forward motion."
|
| 24 |
)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
-
# One live world at a time. On ZeroGPU the rollout runs in a forked worker process, so the held control
|
| 39 |
-
# crosses over a multiprocessing queue created before the fork: buttons put an action, Stop puts None,
|
| 40 |
-
# and the rollout drains the queue once per block.
|
| 41 |
-
commands = multiprocessing.Queue()
|
| 42 |
-
run_lock = threading.Lock()
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
def hold(control):
|
| 46 |
-
commands.put(CONTROLS[control])
|
| 47 |
-
return f"holding: {control}"
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
def stop():
|
| 51 |
-
commands.put(None)
|
| 52 |
-
return "stopping after this block…"
|
| 53 |
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
@spaces.GPU(duration=lambda image, prompt, max_blocks: 30 + 6 * int(max_blocks))
|
| 56 |
-
def rollout(image, prompt, max_blocks):
|
| 57 |
-
held = CONTROLS["▲ forward (W)"]
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
try:
|
| 63 |
-
|
| 64 |
-
except
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
break
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
return None
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
try:
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
try:
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
except queue.Empty:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
break
|
| 92 |
-
if image is None:
|
| 93 |
-
image = load_image("examples/desert_valley.png")
|
| 94 |
-
yield from rollout(image, prompt, max_blocks)
|
| 95 |
-
finally:
|
| 96 |
-
run_lock.release()
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ABot-World on Modular Diffusers — interactive action-conditioned world rollout.
|
| 3 |
+
gradio.Server + WebSocket live-backend edition.
|
| 4 |
|
| 5 |
+
The engine is one `pipe.stream(action_source=...)` loop over the Modular Diffusers
|
| 6 |
+
`ABotWorldStreamingBlocks` preset (huggingface/diffusers#14159): the pipeline polls the
|
| 7 |
+
held keys once per generated block and yields every block's decoded frames.
|
| 8 |
+
The serving shell (gradio.Server, WebSocket frame stream, pacing, per-session queues,
|
| 9 |
+
front-end) is reused from https://huggingface.co/spaces/acvlab/abot-world-interactive.
|
| 10 |
+
|
| 11 |
+
Given an uploaded starting image (i2v conditioning), a scene prompt, and live
|
| 12 |
+
WASD / IJKL controls, the model autoregressively rolls out an action-conditioned
|
| 13 |
+
navigable world and streams decoded frames to the browser over a WebSocket.
|
| 14 |
+
|
| 15 |
+
This mirrors the live backend/infrastructure of
|
| 16 |
+
https://huggingface.co/spaces/Overworld/waypoint-1-5 (gradio.Server for
|
| 17 |
+
ZeroGPU-friendly start/stop + a raw WebSocket for real-time binary JPEG frame
|
| 18 |
+
streaming and control input), with a cleaner custom UI and image-upload seeding.
|
| 19 |
+
|
| 20 |
+
Multi-user safe: every endpoint is keyed by a per-client `session_id` so
|
| 21 |
+
concurrent players never share seed images, frame queues, or status messages.
|
| 22 |
+
|
| 23 |
+
ZeroGPU 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 and
|
| 25 |
+
propagated into the worker thread's gradio request context, so the GPU work is
|
| 26 |
+
billed against the *requesting user's* quota — not the Space owner's.
|
| 27 |
+
|
| 28 |
+
Upstream: https://github.com/amap-cvlab/ABot-World
|
| 29 |
+
Model: https://huggingface.co/YiYiXu/ABot-World-0-5B-LF-Diffusers (built on Wan2.2-TI2V-5B)
|
| 30 |
+
"""
|
| 31 |
+
import os
|
| 32 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 33 |
+
|
| 34 |
+
import spaces # must precede torch / CUDA-touching imports
|
| 35 |
+
|
| 36 |
+
import io
|
| 37 |
+
import time
|
| 38 |
import queue
|
| 39 |
+
import asyncio
|
| 40 |
+
import struct
|
| 41 |
+
import tempfile
|
| 42 |
import threading
|
| 43 |
+
import contextvars
|
| 44 |
+
import uuid
|
| 45 |
+
from collections import deque
|
| 46 |
+
from dataclasses import dataclass, field
|
| 47 |
+
from multiprocessing import Queue as MPQueue
|
| 48 |
+
from pathlib import Path
|
| 49 |
+
from typing import Dict, Optional, Set
|
| 50 |
|
|
|
|
| 51 |
import numpy as np
|
| 52 |
import torch
|
| 53 |
+
from PIL import Image
|
| 54 |
+
|
| 55 |
+
from fastapi import UploadFile, File, WebSocket, WebSocketDisconnect
|
| 56 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 57 |
+
from gradio import Server
|
| 58 |
+
from gradio.context import LocalContext
|
| 59 |
|
| 60 |
from diffusers.modular_pipelines import ABotWorldStreamingBlocks
|
|
|
|
| 61 |
|
| 62 |
+
# ── Repo paths ───────────────────────────────────────────────────────────────
|
| 63 |
+
APP_DIR = Path(__file__).resolve().parent
|
| 64 |
|
| 65 |
+
MODEL_ID = "YiYiXu/ABot-World-0-5B-LF-Diffusers"
|
| 66 |
|
| 67 |
+
# Preset starting-world images bundled with the Space (sourced from the ABot-World
|
| 68 |
+
# repo). Shown in the UI as clickable thumbnails that seed the i2v rollout directly.
|
| 69 |
+
EXAMPLES_DIR = APP_DIR / "examples"
|
| 70 |
+
EXAMPLE_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.
|
| 79 |
+
STREAM_HEIGHT = 704
|
| 80 |
+
STREAM_WIDTH = 1280
|
| 81 |
+
JPEG_QUALITY = 82
|
| 82 |
+
MAX_BLOCKS_PER_SESSION = 512 # hard cap so a session can't run forever
|
| 83 |
+
SESSION_IDLE_TIMEOUT = 600 # seconds; janitor reaps abandoned sessions
|
| 84 |
+
GPU_DURATION = 90 # seconds per @spaces.GPU allocation
|
| 85 |
+
|
| 86 |
+
# ── Real-time pacing configuration ───────────────────────────────────────────
|
| 87 |
+
# The GPU decodes a whole block (12 frames) at once, so all of a block's
|
| 88 |
+
# frames become available in a burst. If we forward them to the browser the
|
| 89 |
+
# instant they finish, the client sees N frames clustered together followed by a
|
| 90 |
+
# gap while the next block generates — the fps counter averages out fine, but
|
| 91 |
+
# the *felt* cadence is bursty. To deliver a steady real-time stream we pace the
|
| 92 |
+
# frames of each block evenly across the time we expect one block to take
|
| 93 |
+
# (mirroring the official ABot-World web_client's block-frame spreader), and we
|
| 94 |
+
# smooth the per-block generation time with an EMA so a single slow/fast block
|
| 95 |
+
# doesn't cause a visible speed-up/slow-down. See gpu_worker_thread().
|
| 96 |
+
PACING_EMA_ALPHA = 0.25 # smoothing factor for per-block generation time
|
| 97 |
+
MIN_PACING_SLEEP = 0.004 # don't bother sleeping for sub-4ms slices
|
| 98 |
+
DEFAULT_BLOCK_SECONDS = 0.5 # initial per-block estimate before first measure
|
| 99 |
+
|
| 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.
|
| 102 |
+
KEY_ORDER = ["W", "A", "S", "D", "I", "J", "K", "L"]
|
| 103 |
|
| 104 |
DEFAULT_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 the
|
| 111 |
+
# GPU inside the @spaces.GPU rollout.
|
| 112 |
+
print(f"[startup] loading {MODEL_ID} ...", flush=True)
|
| 113 |
+
torch.set_grad_enabled(False)
|
| 114 |
+
pipe = ABotWorldStreamingBlocks().init_pipeline(MODEL_ID)
|
| 115 |
+
pipe.load_components(dtype=torch.bfloat16)
|
| 116 |
+
pipe.to("cuda")
|
| 117 |
+
print("[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 |
|
| 123 |
+
def _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 |
+
@dataclass
|
| 131 |
+
class ControlCommand:
|
| 132 |
+
buttons: Set[str]
|
| 133 |
+
prompt: str
|
| 134 |
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
+
@dataclass
|
| 137 |
+
class StopCommand:
|
| 138 |
+
pass
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
# ── Per-session state ────────────────────────────────────────────────────────
|
| 142 |
+
# NOTE on queues: the @spaces.GPU rollout runs in a forked subprocess, so any
|
| 143 |
+
# object it reads must cross the fork boundary. `command_queue` is therefore a
|
| 144 |
+
# multiprocessing Queue (browser controls / stop reach the GPU loop through it).
|
| 145 |
+
# `frame_queue` / `status_queue` are plain queue.Queue used only in the parent
|
| 146 |
+
# process (frames arrive back via the ZeroGPU generator IPC and are forwarded
|
| 147 |
+
# to the WebSocket by the worker thread).
|
| 148 |
+
@dataclass
|
| 149 |
+
class GameSession:
|
| 150 |
+
session_id: str
|
| 151 |
+
command_queue: "MPQueue"
|
| 152 |
+
frame_queue: "queue.Queue"
|
| 153 |
+
status_queue: "queue.Queue"
|
| 154 |
+
stop_event: threading.Event
|
| 155 |
+
seed_path: str
|
| 156 |
+
prompt: str
|
| 157 |
+
seed: int
|
| 158 |
+
worker_thread: Optional[threading.Thread] = None
|
| 159 |
+
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 |
+
pass
|
| 171 |
+
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 worker
|
| 179 |
+
# thread via contextvars.copy_context()).
|
| 180 |
+
_current_status_queue: "contextvars.ContextVar[Optional[queue.Queue]]" = contextvars.ContextVar(
|
| 181 |
+
"abot_status_queue", default=None
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def broadcast_status(msg: str):
|
| 186 |
+
q = _current_status_queue.get()
|
| 187 |
+
if q is None:
|
| 188 |
+
return
|
| 189 |
+
try:
|
| 190 |
+
q.put_nowait(msg)
|
| 191 |
+
except queue.Full:
|
| 192 |
+
pass
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _get_session(session_id: str) -> Optional[GameSession]:
|
| 196 |
+
with _sessions_lock:
|
| 197 |
+
return _sessions.get(session_id)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _drop_session(session_id: str) -> Optional[GameSession]:
|
| 201 |
+
with _sessions_lock:
|
| 202 |
+
return _sessions.pop(session_id, None)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _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_TIMEOUT
|
| 214 |
+
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 |
+
|
| 222 |
+
threading.Thread(target=_reap_idle_sessions, daemon=True).start()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ── GPU worker ───────────────────────────────────────────────────────────────
|
| 226 |
+
def 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 purely
|
| 231 |
+
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 = True
|
| 239 |
+
# Steady send clock: `next_send` is the monotonic time at which the next
|
| 240 |
+
# frame *should* be delivered. Each frame's slot is one smoothed
|
| 241 |
+
# inter-frame interval after the previous, so frames leave at a constant
|
| 242 |
+
# cadence regardless of the bursty block boundaries. `block_seconds` is
|
| 243 |
+
# an EMA of measured per-block generation time (frames/block ÷ that gives
|
| 244 |
+
# the target inter-frame interval).
|
| 245 |
+
block_seconds = DEFAULT_BLOCK_SECONDS
|
| 246 |
+
next_send = None
|
| 247 |
+
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 |
+
break
|
| 253 |
+
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 |
break
|
| 260 |
+
|
| 261 |
+
if first:
|
| 262 |
+
broadcast_status("Rolling out — use WASD / IJKL to steer.")
|
| 263 |
+
first = False
|
| 264 |
+
|
| 265 |
+
# Update the smoothed per-block time on the first frame of each block
|
| 266 |
+
# (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_elapsed
|
| 270 |
+
+ (1.0 - PACING_EMA_ALPHA) * block_seconds
|
| 271 |
+
)
|
| 272 |
+
fpb = max(1, frames_in_block)
|
| 273 |
+
interval = block_seconds / fpb # target seconds between frames
|
| 274 |
+
|
| 275 |
+
# ── Steady-cadence gate ──────────────────────────────────────────
|
| 276 |
+
# Hold each frame until its scheduled slot so the parent emits at a
|
| 277 |
+
# constant interval instead of dumping a whole block at once.
|
| 278 |
+
now = time.time()
|
| 279 |
+
if next_send is None:
|
| 280 |
+
next_send = now
|
| 281 |
+
sleep_for = next_send - now
|
| 282 |
+
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 |
+
break
|
| 286 |
+
now = time.time()
|
| 287 |
+
# Advance the schedule; if we've fallen far behind (e.g. a long GPU
|
| 288 |
+
# 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.0
|
| 294 |
+
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.0
|
| 297 |
+
# Keep only the freshest frame if the consumer fell behind: coalesce
|
| 298 |
+
# 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 |
+
break
|
| 304 |
+
try:
|
| 305 |
+
session.frame_queue.put_nowait((frame, block_idx, round(fps, 1)))
|
| 306 |
+
except queue.Full:
|
| 307 |
+
pass
|
| 308 |
+
finally:
|
| 309 |
+
session.stop_event.set()
|
| 310 |
+
print("Worker thread finished", flush=True)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def 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 the
|
| 317 |
+
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_PROMPT
|
| 322 |
+
image = Image.open(seed_path).convert("RGB")
|
| 323 |
+
state = {"action": _action_from_buttons({"W"}), "block_start": time.time()} # default: forward
|
| 324 |
+
|
| 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 None
|
| 329 |
+
while True:
|
| 330 |
+
try:
|
| 331 |
+
cmd = command_queue.get_nowait()
|
| 332 |
+
except Exception:
|
| 333 |
+
break
|
| 334 |
+
if isinstance(cmd, StopCommand):
|
| 335 |
+
return None
|
| 336 |
+
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 |
+
action_source=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 events
|
| 353 |
+
# Time the full generate+decode of one block so the parent thread
|
| 354 |
+
# 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 parent
|
| 362 |
+
# 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) ──────────────────────────────────────────────────────
|
| 369 |
+
app = Server()
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@app.api(name="start_game")
|
| 373 |
+
def 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 image
|
| 380 |
+
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 boundary
|
| 398 |
+
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] = session
|
| 414 |
+
|
| 415 |
+
# Capture the *incoming request* — HF has already injected this user's
|
| 416 |
+
# ZeroGPU proxy token (x-ip-token / x-api-token) into its headers. We
|
| 417 |
+
# re-set it into the worker thread's gradio LocalContext so that
|
| 418 |
+
# @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 the
|
| 426 |
+
# 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 |
+
pass
|
| 432 |
+
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 = worker
|
| 437 |
+
worker.start()
|
| 438 |
+
finally:
|
| 439 |
+
_current_status_queue.reset(status_token)
|
| 440 |
+
|
| 441 |
+
return session_id
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
@app.api(name="stop_game")
|
| 445 |
+
def 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")
|
| 456 |
+
async 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 |
+
return
|
| 463 |
+
|
| 464 |
+
loop = asyncio.get_event_loop()
|
| 465 |
+
|
| 466 |
+
async def send_frames():
|
| 467 |
+
session_ended_sent = False
|
| 468 |
+
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 |
+
break
|
| 477 |
+
await websocket.send_json({"type": "status", "message": status_msg})
|
| 478 |
+
except queue.Empty:
|
| 479 |
+
pass
|
| 480 |
+
except (WebSocketDisconnect, RuntimeError):
|
| 481 |
+
break
|
| 482 |
+
|
| 483 |
+
if session is None:
|
| 484 |
+
await asyncio.sleep(0.05)
|
| 485 |
+
continue
|
| 486 |
+
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 |
+
break
|
| 492 |
+
session_ended_sent = True
|
| 493 |
+
await asyncio.sleep(0.4)
|
| 494 |
+
continue
|
| 495 |
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 = result
|
| 500 |
+
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 |
+
pass
|
| 509 |
+
except (WebSocketDisconnect, RuntimeError):
|
| 510 |
+
break
|
| 511 |
+
|
| 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 |
+
continue
|
| 519 |
+
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 |
+
pass
|
| 530 |
+
elif msg_type == "stop":
|
| 531 |
+
session.stop()
|
| 532 |
+
except WebSocketDisconnect:
|
| 533 |
+
break
|
| 534 |
+
except Exception:
|
| 535 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
|
| 537 |
+
try:
|
| 538 |
+
await asyncio.gather(send_frames(), receive_controls())
|
| 539 |
+
except WebSocketDisconnect:
|
| 540 |
+
pass
|
| 541 |
|
| 542 |
+
|
| 543 |
+
@app.post("/upload_seed")
|
| 544 |
+
async 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 as
|
| 548 |
+
`seed_path` to seed the image-to-video (i2v) world rollout. Only an image is
|
| 549 |
+
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 |
+
|
| 562 |
+
def _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 None
|
| 566 |
+
path = (EXAMPLES_DIR / name).resolve()
|
| 567 |
+
if EXAMPLES_DIR.resolve() not in path.parents or not path.is_file():
|
| 568 |
+
return None
|
| 569 |
+
return path
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
@app.get("/example_seeds")
|
| 573 |
+
async 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")
|
| 579 |
+
async 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")
|
| 588 |
+
async 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)
|
| 607 |
+
async 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.
|
| 614 |
+
spaces.GPU(lambda: None)
|
| 615 |
+
|
| 616 |
+
app.launch(server_name="0.0.0.0", server_port=7860)
|
index.html
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
| 6 |
+
<title>ABot-World · Interactive World Rollout · Modular Diffusers</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0b0e14; --panel:#121722; --panel-2:#171d2b; --edge:#232c3d;
|
| 10 |
+
--text:#e6ecf5; --muted:#8a97ad; --accent:#5b8cff; --accent-2:#7c5cff;
|
| 11 |
+
--good:#3ecf8e; --warn:#ffb454; --bad:#ff5c6c; --radius:16px;
|
| 12 |
+
}
|
| 13 |
+
*{box-sizing:border-box}
|
| 14 |
+
html,body{margin:0;height:100%}
|
| 15 |
+
body{
|
| 16 |
+
font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
| 17 |
+
background:radial-gradient(1200px 700px at 80% -10%, #1a2138 0%, var(--bg) 60%) fixed;
|
| 18 |
+
color:var(--text); -webkit-font-smoothing:antialiased; min-height:100%;
|
| 19 |
+
}
|
| 20 |
+
.wrap{max-width:1240px; margin:0 auto; padding:28px 20px 48px}
|
| 21 |
+
header{display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:22px}
|
| 22 |
+
.brand{display:flex; align-items:center; gap:12px}
|
| 23 |
+
.logo{width:42px;height:42px;border-radius:12px;
|
| 24 |
+
background:linear-gradient(135deg,var(--accent),var(--accent-2));
|
| 25 |
+
display:grid;place-items:center;font-size:22px;box-shadow:0 6px 24px rgba(91,140,255,.35)}
|
| 26 |
+
.brand h1{font-size:19px;margin:0;letter-spacing:.2px}
|
| 27 |
+
.brand p{margin:2px 0 0;font-size:12.5px;color:var(--muted)}
|
| 28 |
+
.links{display:flex;gap:8px;flex-wrap:wrap}
|
| 29 |
+
.links a{font-size:12.5px;color:var(--muted);text-decoration:none;border:1px solid var(--edge);
|
| 30 |
+
padding:6px 11px;border-radius:999px;transition:.15s}
|
| 31 |
+
.links a:hover{color:var(--text);border-color:var(--accent);background:rgba(91,140,255,.08)}
|
| 32 |
+
|
| 33 |
+
.layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:20px}
|
| 34 |
+
@media (max-width:960px){.layout{grid-template-columns:1fr}}
|
| 35 |
+
|
| 36 |
+
.card{background:linear-gradient(180deg,var(--panel),var(--panel-2));
|
| 37 |
+
border:1px solid var(--edge);border-radius:var(--radius);padding:16px}
|
| 38 |
+
|
| 39 |
+
/* Stage */
|
| 40 |
+
.stage{position:relative;aspect-ratio:1280/704;background:#05070c;border-radius:12px;
|
| 41 |
+
overflow:hidden;border:1px solid var(--edge);display:grid;place-items:center}
|
| 42 |
+
.stage canvas{width:100%;height:100%;object-fit:cover;display:none;image-rendering:auto}
|
| 43 |
+
.stage.playing canvas{display:block}
|
| 44 |
+
.overlay{position:absolute;inset:0;display:grid;place-items:center;text-align:center;
|
| 45 |
+
padding:24px;background:rgba(5,7,12,.55);backdrop-filter:blur(2px)}
|
| 46 |
+
.overlay.hidden{display:none}
|
| 47 |
+
.spinner{width:34px;height:34px;border-radius:50%;border:3px solid rgba(255,255,255,.15);
|
| 48 |
+
border-top-color:var(--accent);animation:spin 1s linear infinite;margin:0 auto 14px}
|
| 49 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 50 |
+
.overlay h3{margin:0 0 6px;font-size:16px}
|
| 51 |
+
.overlay p{margin:0;color:var(--muted);font-size:13px;max-width:420px}
|
| 52 |
+
|
| 53 |
+
.hud{position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;
|
| 54 |
+
pointer-events:none;font-size:12px}
|
| 55 |
+
.chip{background:rgba(9,12,20,.7);border:1px solid var(--edge);border-radius:999px;
|
| 56 |
+
padding:5px 11px;display:flex;align-items:center;gap:7px;backdrop-filter:blur(4px)}
|
| 57 |
+
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
| 58 |
+
.dot.playing{background:var(--good);box-shadow:0 0 8px var(--good)}
|
| 59 |
+
.dot.loading{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite}
|
| 60 |
+
.dot.error,.dot.ended{background:var(--bad)}
|
| 61 |
+
@keyframes pulse{50%{opacity:.35}}
|
| 62 |
+
|
| 63 |
+
.capture-hint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
| 64 |
+
background:rgba(9,12,20,.72);border:1px solid var(--edge);border-radius:999px;
|
| 65 |
+
padding:6px 14px;font-size:12px;color:var(--muted);pointer-events:none;transition:.2s;opacity:0}
|
| 66 |
+
.stage.playing .capture-hint{opacity:1}
|
| 67 |
+
|
| 68 |
+
/* Uploader */
|
| 69 |
+
.drop{border:1.5px dashed var(--edge);border-radius:12px;padding:16px;text-align:center;
|
| 70 |
+
cursor:pointer;transition:.15s;background:rgba(255,255,255,.015)}
|
| 71 |
+
.drop:hover,.drop.drag{border-color:var(--accent);background:rgba(91,140,255,.07)}
|
| 72 |
+
.drop .icon{font-size:26px}
|
| 73 |
+
.drop .t{font-size:13.5px;margin-top:6px}
|
| 74 |
+
.drop .s{font-size:11.5px;color:var(--muted);margin-top:2px}
|
| 75 |
+
.thumb{display:none;margin-top:10px;border-radius:10px;overflow:hidden;border:1px solid var(--edge)}
|
| 76 |
+
.thumb img{width:100%;display:block;max-height:170px;object-fit:cover}
|
| 77 |
+
.thumb.show{display:block}
|
| 78 |
+
|
| 79 |
+
/* Preset starting-world thumbnails */
|
| 80 |
+
.presets{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:10px}
|
| 81 |
+
.preset{position:relative;border:1px solid var(--edge);border-radius:9px;overflow:hidden;
|
| 82 |
+
cursor:pointer;background:#0d1220;aspect-ratio:16/9;transition:.15s}
|
| 83 |
+
.preset:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(91,140,255,.25)}
|
| 84 |
+
.preset.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(91,140,255,.45)}
|
| 85 |
+
.preset img{width:100%;height:100%;object-fit:cover;display:block}
|
| 86 |
+
.preset .pl{position:absolute;left:0;right:0;bottom:0;font-size:10px;color:#fff;
|
| 87 |
+
padding:3px 5px;background:linear-gradient(0deg,rgba(5,7,12,.85),transparent);
|
| 88 |
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 89 |
+
.or-sep{font-size:11px;color:var(--muted);text-align:center;margin:12px 0 8px;
|
| 90 |
+
text-transform:uppercase;letter-spacing:.7px}
|
| 91 |
+
|
| 92 |
+
.field{margin-top:14px}
|
| 93 |
+
.label{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin-bottom:6px}
|
| 94 |
+
textarea,input[type=number]{width:100%;background:#0d1220;color:var(--text);border:1px solid var(--edge);
|
| 95 |
+
border-radius:10px;padding:10px 12px;font-size:13.5px;font-family:inherit;resize:vertical}
|
| 96 |
+
textarea:focus,input:focus{outline:none;border-color:var(--accent)}
|
| 97 |
+
.row{display:flex;gap:10px}
|
| 98 |
+
.row>*{flex:1}
|
| 99 |
+
|
| 100 |
+
.btns{display:flex;gap:10px;margin-top:16px}
|
| 101 |
+
button{border:none;border-radius:10px;padding:11px 14px;font-size:13.5px;font-weight:600;
|
| 102 |
+
cursor:pointer;font-family:inherit;transition:.15s;flex:1}
|
| 103 |
+
.primary{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff;
|
| 104 |
+
box-shadow:0 6px 20px rgba(91,140,255,.32)}
|
| 105 |
+
.primary:hover{filter:brightness(1.07)}
|
| 106 |
+
.ghost{background:transparent;color:var(--muted);border:1px solid var(--edge)}
|
| 107 |
+
.ghost:hover{color:var(--text);border-color:var(--accent)}
|
| 108 |
+
button:disabled{opacity:.45;cursor:not-allowed;filter:none}
|
| 109 |
+
|
| 110 |
+
/* Controls section (below the stage) */
|
| 111 |
+
.controls-section{margin-top:20px}
|
| 112 |
+
.controls-grid{display:grid;grid-template-columns:auto 1fr;gap:28px;align-items:center;margin-top:12px}
|
| 113 |
+
@media (max-width:640px){.controls-grid{grid-template-columns:1fr;gap:20px}}
|
| 114 |
+
|
| 115 |
+
/* Visual on-screen WASD keyboard */
|
| 116 |
+
.keyboard{display:grid;grid-template-columns:repeat(3,56px);grid-template-rows:repeat(2,56px);
|
| 117 |
+
gap:8px;justify-content:start}
|
| 118 |
+
.keycap{display:grid;place-items:center;border-radius:12px;background:#0d1220;
|
| 119 |
+
border:1px solid var(--edge);font-size:18px;color:var(--muted);font-weight:700;
|
| 120 |
+
transition:.08s;user-select:none}
|
| 121 |
+
.keycap.spacer{background:transparent;border:none}
|
| 122 |
+
.keycap.active{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff;
|
| 123 |
+
border-color:transparent;box-shadow:0 4px 16px rgba(91,140,255,.45);transform:translateY(1px)}
|
| 124 |
+
/* WASD cross placement inside the 3x2 grid */
|
| 125 |
+
.keycap[data-k="W"]{grid-column:2;grid-row:1}
|
| 126 |
+
.keycap[data-k="A"]{grid-column:1;grid-row:2}
|
| 127 |
+
.keycap[data-k="S"]{grid-column:2;grid-row:2}
|
| 128 |
+
.keycap[data-k="D"]{grid-column:3;grid-row:2}
|
| 129 |
+
/* IJKL look cross placement inside its own 3x2 grid */
|
| 130 |
+
.keycap[data-k="I"]{grid-column:2;grid-row:1}
|
| 131 |
+
.keycap[data-k="J"]{grid-column:1;grid-row:2}
|
| 132 |
+
.keycap[data-k="K"]{grid-column:2;grid-row:2}
|
| 133 |
+
.keycap[data-k="L"]{grid-column:3;grid-row:2}
|
| 134 |
+
|
| 135 |
+
.look-info{font-size:12.5px;color:var(--muted);line-height:1.6}
|
| 136 |
+
.look-info b{color:var(--text)}
|
| 137 |
+
.look-badge{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--good);
|
| 138 |
+
border:1px solid var(--edge);border-radius:999px;padding:4px 10px;margin-top:8px}
|
| 139 |
+
.look-badge .d{width:7px;height:7px;border-radius:50%;background:var(--muted)}
|
| 140 |
+
.look-badge.active .d{background:var(--good);box-shadow:0 0 8px var(--good)}
|
| 141 |
+
.hint-line{font-size:11.5px;color:var(--muted);margin-top:14px;line-height:1.55}
|
| 142 |
+
.active-tags{font-size:12px;color:var(--good);min-height:16px;margin-top:8px}
|
| 143 |
+
.footnote{font-size:11px;color:var(--muted);margin-top:12px;line-height:1.5}
|
| 144 |
+
</style>
|
| 145 |
+
</head>
|
| 146 |
+
<body>
|
| 147 |
+
<div class="wrap">
|
| 148 |
+
<header>
|
| 149 |
+
<div class="brand">
|
| 150 |
+
<div class="logo">🌍</div>
|
| 151 |
+
<div>
|
| 152 |
+
<h1>ABot-World — Interactive World Rollout on Modular Diffusers</h1>
|
| 153 |
+
<p>Upload a starting frame, then steer a live action-conditioned world with WASD + IJKL.</p>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="links">
|
| 157 |
+
<a href="https://huggingface.co/YiYiXu/ABot-World-0-5B-LF-Diffusers" target="_blank">Model</a>
|
| 158 |
+
<a href="https://github.com/huggingface/diffusers/pull/14159" target="_blank">Modular streaming API</a>
|
| 159 |
+
<a href="https://amap-cvlab.github.io/ABot-World/" target="_blank">Project</a>
|
| 160 |
+
<a href="https://github.com/amap-cvlab/ABot-World" target="_blank">Code</a>
|
| 161 |
+
</div>
|
| 162 |
+
</header>
|
| 163 |
+
|
| 164 |
+
<div class="layout">
|
| 165 |
+
<!-- Stage -->
|
| 166 |
+
<div class="card">
|
| 167 |
+
<div class="stage" id="stage" tabindex="0">
|
| 168 |
+
<canvas id="view" width="1280" height="704"></canvas>
|
| 169 |
+
<div class="hud">
|
| 170 |
+
<div class="chip"><span class="dot" id="status-dot"></span><span id="status-label">Idle</span></div>
|
| 171 |
+
<div class="chip"><span id="fps-value">0</span> fps · <span id="frame-value">0</span> blk</div>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="capture-hint" id="capture-hint">Click to capture · WASD to move · IJKL to look · Esc to release</div>
|
| 174 |
+
<div class="overlay" id="overlay">
|
| 175 |
+
<div>
|
| 176 |
+
<div class="spinner" id="spinner" style="display:none"></div>
|
| 177 |
+
<h3 id="overlay-title">Upload an image to begin</h3>
|
| 178 |
+
<p id="overlay-sub">Pick a starting frame on the right, add a scene prompt, and press <b>Start world</b>.</p>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<!-- Controls (directly below the video output) -->
|
| 184 |
+
<div class="controls-section">
|
| 185 |
+
<div class="controls-grid">
|
| 186 |
+
<div>
|
| 187 |
+
<div class="label" style="margin-bottom:8px">Movement</div>
|
| 188 |
+
<div class="keyboard" id="keys">
|
| 189 |
+
<span class="keycap" data-k="W">W</span>
|
| 190 |
+
<span class="keycap" data-k="A">A</span>
|
| 191 |
+
<span class="keycap" data-k="S">S</span>
|
| 192 |
+
<span class="keycap" data-k="D">D</span>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
<div>
|
| 196 |
+
<div class="label" style="margin-bottom:8px">Look / pan</div>
|
| 197 |
+
<div class="keyboard" id="look-keys">
|
| 198 |
+
<span class="keycap" data-k="I">I</span>
|
| 199 |
+
<span class="keycap" data-k="J">J</span>
|
| 200 |
+
<span class="keycap" data-k="K">K</span>
|
| 201 |
+
<span class="keycap" data-k="L">L</span>
|
| 202 |
+
</div>
|
| 203 |
+
<div class="look-badge" id="look-badge"><span class="d"></span><span id="look-badge-text">Look idle</span></div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="hint-line">
|
| 207 |
+
<b>W A S D</b> — move & turn (hold to steer the next block) ·
|
| 208 |
+
<b>I J K L</b> — look / pan. Click the video to capture input.
|
| 209 |
+
</div>
|
| 210 |
+
<div class="active-tags" id="active-tags"></div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<!-- Controls -->
|
| 215 |
+
<div class="card">
|
| 216 |
+
<div class="label">Starting image (i2v seed)</div>
|
| 217 |
+
<div class="presets" id="presets"></div>
|
| 218 |
+
<div class="or-sep">or upload your own</div>
|
| 219 |
+
<div class="drop" id="drop">
|
| 220 |
+
<div class="icon">🖼️</div>
|
| 221 |
+
<div class="t">Drop an image or click to upload</div>
|
| 222 |
+
<div class="s">PNG / JPG · becomes the world's first frame</div>
|
| 223 |
+
</div>
|
| 224 |
+
<input type="file" id="file-input" accept="image/*" style="display:none" />
|
| 225 |
+
<div class="thumb" id="thumb"><img id="thumb-img" alt="seed preview" /></div>
|
| 226 |
+
|
| 227 |
+
<div class="field">
|
| 228 |
+
<div class="label">Scene prompt</div>
|
| 229 |
+
<textarea id="prompt" rows="3">A realistic outdoor world scene with a navigable path, natural lighting, detailed ground texture, and stable forward motion.</textarea>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div class="field row">
|
| 233 |
+
<div>
|
| 234 |
+
<div class="label">Seed</div>
|
| 235 |
+
<input type="number" id="seed" value="42" />
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div class="btns">
|
| 240 |
+
<button class="primary" id="start-btn" disabled>Start world</button>
|
| 241 |
+
<button class="ghost" id="stop-btn" disabled>Stop</button>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<script type="module">
|
| 248 |
+
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 249 |
+
|
| 250 |
+
// One session id per tab keeps every server-side queue isolated per user.
|
| 251 |
+
const SESSION_ID = (crypto.randomUUID && crypto.randomUUID()) ||
|
| 252 |
+
(`s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`);
|
| 253 |
+
|
| 254 |
+
// Movement keys are driven by the keyboard (WASD); look/pan is driven by the
|
| 255 |
+
// keyboard too (I J K L) — both are handled as held-key controls.
|
| 256 |
+
const MOVE_KEYS = new Set(["W","A","S","D"]);
|
| 257 |
+
const LOOK_KEYS = new Set(["I","J","K","L"]);
|
| 258 |
+
const KEY_FROM_CODE = {
|
| 259 |
+
KeyW:"W", KeyA:"A", KeyS:"S", KeyD:"D",
|
| 260 |
+
KeyI:"I", KeyJ:"J", KeyK:"K", KeyL:"L",
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
const state = {
|
| 264 |
+
sessionId: SESSION_ID,
|
| 265 |
+
client: null,
|
| 266 |
+
ws: null,
|
| 267 |
+
playing: false,
|
| 268 |
+
capturing: false,
|
| 269 |
+
pressed: new Set(),
|
| 270 |
+
seedPath: "",
|
| 271 |
+
prompt: "",
|
| 272 |
+
};
|
| 273 |
+
|
| 274 |
+
// --- DOM ---
|
| 275 |
+
const $ = (id) => document.getElementById(id);
|
| 276 |
+
const stage = $("stage"), view = $("view"), overlay = $("overlay");
|
| 277 |
+
const viewCtx = view.getContext("2d", { alpha: false, desynchronized: true });
|
| 278 |
+
const spinner = $("spinner"), overlayTitle = $("overlay-title"), overlaySub = $("overlay-sub");
|
| 279 |
+
const statusDot = $("status-dot"), statusLabel = $("status-label");
|
| 280 |
+
const fpsValue = $("fps-value"), frameValue = $("frame-value");
|
| 281 |
+
const drop = $("drop"), fileInput = $("file-input"), thumb = $("thumb"), thumbImg = $("thumb-img");
|
| 282 |
+
const presetsEl = $("presets");
|
| 283 |
+
const promptEl = $("prompt"), seedEl = $("seed");
|
| 284 |
+
const startBtn = $("start-btn"), stopBtn = $("stop-btn");
|
| 285 |
+
const activeTags = $("active-tags");
|
| 286 |
+
const keycaps = Array.from(document.querySelectorAll(".keycap[data-k]"));
|
| 287 |
+
const lookBadge = $("look-badge"), lookBadgeText = $("look-badge-text");
|
| 288 |
+
|
| 289 |
+
// --- UI helpers ---
|
| 290 |
+
function setStatus(kind, label){
|
| 291 |
+
statusDot.className = "dot " + kind;
|
| 292 |
+
statusLabel.textContent = label;
|
| 293 |
+
}
|
| 294 |
+
function showOverlay(title, sub, loading){
|
| 295 |
+
overlay.classList.remove("hidden");
|
| 296 |
+
overlayTitle.textContent = title;
|
| 297 |
+
overlaySub.textContent = sub || "";
|
| 298 |
+
spinner.style.display = loading ? "block" : "none";
|
| 299 |
+
}
|
| 300 |
+
function hideOverlay(){ overlay.classList.add("hidden"); }
|
| 301 |
+
function setPlaying(on){
|
| 302 |
+
state.playing = on;
|
| 303 |
+
stage.classList.toggle("playing", on);
|
| 304 |
+
stopBtn.disabled = !on;
|
| 305 |
+
}
|
| 306 |
+
function refreshStartEnabled(){
|
| 307 |
+
startBtn.disabled = !state.seedPath || state.playing;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// --- Upload ---
|
| 311 |
+
async function uploadFile(file){
|
| 312 |
+
if(!file || !file.type.startsWith("image/")){ return; }
|
| 313 |
+
clearPresetSelection();
|
| 314 |
+
// Local preview
|
| 315 |
+
const reader = new FileReader();
|
| 316 |
+
reader.onload = (e)=>{ thumbImg.src = e.target.result; thumb.classList.add("show"); };
|
| 317 |
+
reader.readAsDataURL(file);
|
| 318 |
+
|
| 319 |
+
drop.querySelector(".t").textContent = "Uploading…";
|
| 320 |
+
const fd = new FormData();
|
| 321 |
+
fd.append("file", file, file.name || "seed.png");
|
| 322 |
+
try{
|
| 323 |
+
const res = await fetch(`${window.location.origin}/upload_seed`, { method:"POST", body:fd });
|
| 324 |
+
const data = await res.json();
|
| 325 |
+
if(data.seed_path){
|
| 326 |
+
state.seedPath = data.seed_path;
|
| 327 |
+
drop.querySelector(".t").textContent = "Image ready ✓ — click to replace";
|
| 328 |
+
showOverlay("Ready to explore", "Press Start world to begin the rollout.", false);
|
| 329 |
+
}else{
|
| 330 |
+
drop.querySelector(".t").textContent = "Upload failed — try another image";
|
| 331 |
+
}
|
| 332 |
+
}catch(err){
|
| 333 |
+
console.error(err);
|
| 334 |
+
drop.querySelector(".t").textContent = "Upload failed — try again";
|
| 335 |
+
}
|
| 336 |
+
refreshStartEnabled();
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
drop.addEventListener("click", ()=>fileInput.click());
|
| 340 |
+
fileInput.addEventListener("change", (e)=>uploadFile(e.target.files[0]));
|
| 341 |
+
["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,(e)=>{e.preventDefault();drop.classList.add("drag");}));
|
| 342 |
+
["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,(e)=>{e.preventDefault();drop.classList.remove("drag");}));
|
| 343 |
+
drop.addEventListener("drop",(e)=>{ if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); });
|
| 344 |
+
|
| 345 |
+
// --- Preset starting-world thumbnails (seed rollout directly, no upload) ---
|
| 346 |
+
function clearPresetSelection(){
|
| 347 |
+
presetsEl.querySelectorAll(".preset").forEach(p=>p.classList.remove("selected"));
|
| 348 |
+
}
|
| 349 |
+
async function pickPreset(name, el){
|
| 350 |
+
clearPresetSelection();
|
| 351 |
+
el.classList.add("selected");
|
| 352 |
+
// Show the chosen preset in the seed preview too.
|
| 353 |
+
thumbImg.src = `${window.location.origin}/example_thumb?name=${encodeURIComponent(name)}`;
|
| 354 |
+
thumb.classList.add("show");
|
| 355 |
+
drop.querySelector(".t").textContent = "Loading preset…";
|
| 356 |
+
try{
|
| 357 |
+
const res = await fetch(`${window.location.origin}/example_seed?name=${encodeURIComponent(name)}`);
|
| 358 |
+
const data = await res.json();
|
| 359 |
+
if(data.seed_path){
|
| 360 |
+
state.seedPath = data.seed_path;
|
| 361 |
+
drop.querySelector(".t").textContent = "Preset ready ✓ — or upload to replace";
|
| 362 |
+
showOverlay("Ready to explore", "Press Start world to begin the rollout.", false);
|
| 363 |
+
}else{
|
| 364 |
+
drop.querySelector(".t").textContent = "Could not load preset — try another";
|
| 365 |
+
el.classList.remove("selected");
|
| 366 |
+
}
|
| 367 |
+
}catch(err){
|
| 368 |
+
console.error(err);
|
| 369 |
+
drop.querySelector(".t").textContent = "Could not load preset — try again";
|
| 370 |
+
el.classList.remove("selected");
|
| 371 |
+
}
|
| 372 |
+
refreshStartEnabled();
|
| 373 |
+
}
|
| 374 |
+
async function loadPresets(){
|
| 375 |
+
try{
|
| 376 |
+
const res = await fetch(`${window.location.origin}/example_seeds`);
|
| 377 |
+
const data = await res.json();
|
| 378 |
+
(data.examples || []).forEach(ex=>{
|
| 379 |
+
const div = document.createElement("div");
|
| 380 |
+
div.className = "preset";
|
| 381 |
+
div.title = ex.label || ex.name;
|
| 382 |
+
div.innerHTML =
|
| 383 |
+
`<img loading="lazy" alt="${ex.label || ex.name}" ` +
|
| 384 |
+
`src="${window.location.origin}/example_thumb?name=${encodeURIComponent(ex.name)}" />` +
|
| 385 |
+
`<span class="pl">${ex.label || ex.name}</span>`;
|
| 386 |
+
div.addEventListener("click", ()=>pickPreset(ex.name, div));
|
| 387 |
+
presetsEl.appendChild(div);
|
| 388 |
+
});
|
| 389 |
+
}catch(err){ console.error("presets load failed", err); }
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// --- Gradio client (auto-forwards the HF iframe x-api-token for quota) ---
|
| 393 |
+
async function initClient(){
|
| 394 |
+
if(!state.client) state.client = await Client.connect(window.location.origin);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// --- Frame decode + jitter buffer + steady render clock ---
|
| 398 |
+
// Rendering is DECOUPLED from network arrival. WebSocket messages only decode
|
| 399 |
+
// frames into a small jitter buffer; a steady render clock (requestAnimationFrame)
|
| 400 |
+
// then pulls the freshest decoded frame at a smoothed target interval and paints
|
| 401 |
+
// it. This absorbs network jitter and the server's block-boundary bursts so the
|
| 402 |
+
// visible cadence stays even instead of "burst of N frames, then a gap".
|
| 403 |
+
//
|
| 404 |
+
// The buffer is intentionally shallow (JITTER_MAX): if frames arrive faster than
|
| 405 |
+
// we render, older ones are dropped (coalesced) rather than queued, so we never
|
| 406 |
+
// accumulate buffering delay that would later flush as a burst of stale frames.
|
| 407 |
+
const JITTER_MAX = 4; // max decoded frames held before we drop-oldest
|
| 408 |
+
const JITTER_MIN = 2; // frames to buffer before the render clock starts
|
| 409 |
+
// (a 2-frame lead absorbs network gaps so the
|
| 410 |
+
// render clock rarely starves between bursts)
|
| 411 |
+
let jitterBuf = []; // [{bitmap, blk, fps}] decoded, awaiting display
|
| 412 |
+
let renderPrimed = false; // becomes true once JITTER_MIN frames buffered
|
| 413 |
+
let renderInterval = 1000 / 16; // ms between paints; adapted to server fps (EMA)
|
| 414 |
+
let lastPaint = 0; // performance.now() of last painted frame
|
| 415 |
+
let lastArrival = 0; // performance.now() of last WS frame (for interval est.)
|
| 416 |
+
let arrivalEma = 0; // EMA of inter-arrival gap (ms)
|
| 417 |
+
|
| 418 |
+
async function ingestFrame(bytes, blk, fps){
|
| 419 |
+
// Estimate the true incoming cadence from arrival timing and adapt the render
|
| 420 |
+
// interval toward it (bounded), so the steady clock matches the real fps.
|
| 421 |
+
const now = performance.now();
|
| 422 |
+
if(lastArrival){
|
| 423 |
+
const gap = now - lastArrival;
|
| 424 |
+
arrivalEma = arrivalEma ? (0.2 * gap + 0.8 * arrivalEma) : gap;
|
| 425 |
+
const tgt = Math.min(200, Math.max(20, arrivalEma)); // clamp 5–50 fps
|
| 426 |
+
renderInterval = 0.2 * tgt + 0.8 * renderInterval;
|
| 427 |
+
}
|
| 428 |
+
lastArrival = now;
|
| 429 |
+
|
| 430 |
+
let bitmap = null;
|
| 431 |
+
try{
|
| 432 |
+
bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"}));
|
| 433 |
+
}catch(err){
|
| 434 |
+
// Fallback path for browsers without createImageBitmap JPEG support:
|
| 435 |
+
// paint straight to canvas (bypasses buffer but keeps the stream alive).
|
| 436 |
+
await paintViaImage(bytes, blk, fps);
|
| 437 |
+
return;
|
| 438 |
+
}
|
| 439 |
+
jitterBuf.push({ bitmap, blk, fps });
|
| 440 |
+
// Drop-oldest to keep the buffer shallow (avoid stale-frame burst on flush).
|
| 441 |
+
while(jitterBuf.length > JITTER_MAX){
|
| 442 |
+
const stale = jitterBuf.shift();
|
| 443 |
+
if(stale.bitmap && stale.bitmap.close) stale.bitmap.close();
|
| 444 |
+
}
|
| 445 |
+
if(!renderPrimed && jitterBuf.length >= JITTER_MIN){
|
| 446 |
+
renderPrimed = true;
|
| 447 |
+
lastPaint = performance.now() - renderInterval; // paint first frame promptly
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
function paintBitmap(entry){
|
| 452 |
+
viewCtx.drawImage(entry.bitmap, 0, 0, view.width, view.height);
|
| 453 |
+
if(entry.bitmap && entry.bitmap.close) entry.bitmap.close();
|
| 454 |
+
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 455 |
+
frameValue.textContent = entry.blk;
|
| 456 |
+
fpsValue.textContent = entry.fps.toFixed(1);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Steady render clock: runs continuously via rAF while playing; pulls at most one
|
| 460 |
+
// buffered frame per render-interval so display timing is even, not arrival-tied.
|
| 461 |
+
function renderClock(){
|
| 462 |
+
requestAnimationFrame(renderClock);
|
| 463 |
+
if(!renderPrimed || jitterBuf.length === 0) return;
|
| 464 |
+
const now = performance.now();
|
| 465 |
+
if(now - lastPaint < renderInterval) return; // not yet this frame's slot
|
| 466 |
+
const entry = jitterBuf.shift(); // freshest frames kept; oldest painted in order
|
| 467 |
+
lastPaint = now;
|
| 468 |
+
paintBitmap(entry);
|
| 469 |
+
}
|
| 470 |
+
requestAnimationFrame(renderClock);
|
| 471 |
+
|
| 472 |
+
function resetJitterBuffer(){
|
| 473 |
+
for(const e of jitterBuf){ if(e.bitmap && e.bitmap.close) e.bitmap.close(); }
|
| 474 |
+
jitterBuf = [];
|
| 475 |
+
renderPrimed = false;
|
| 476 |
+
lastArrival = 0;
|
| 477 |
+
arrivalEma = 0;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function paintViaImage(bytes, blk, fps){
|
| 481 |
+
return new Promise((resolve)=>{
|
| 482 |
+
const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
|
| 483 |
+
const im = new Image();
|
| 484 |
+
im.onload = ()=>{
|
| 485 |
+
viewCtx.drawImage(im, 0, 0, view.width, view.height);
|
| 486 |
+
URL.revokeObjectURL(url);
|
| 487 |
+
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 488 |
+
if(typeof blk !== "undefined") frameValue.textContent = blk;
|
| 489 |
+
if(typeof fps !== "undefined") fpsValue.textContent = fps.toFixed(1);
|
| 490 |
+
resolve(true);
|
| 491 |
+
};
|
| 492 |
+
im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
|
| 493 |
+
im.src = url;
|
| 494 |
+
});
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// --- WebSocket frame stream ---
|
| 498 |
+
function connectWS(){
|
| 499 |
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
| 500 |
+
const ws = new WebSocket(`${proto}//${location.host}/ws?session_id=${encodeURIComponent(state.sessionId)}`);
|
| 501 |
+
ws.binaryType = "arraybuffer";
|
| 502 |
+
ws.onmessage = (e)=>{
|
| 503 |
+
if(e.data instanceof ArrayBuffer){
|
| 504 |
+
const dv = new DataView(e.data);
|
| 505 |
+
const blk = dv.getUint32(0);
|
| 506 |
+
const fps = dv.getUint32(4)/10;
|
| 507 |
+
// Decode into the jitter buffer; the steady render clock paints on its own
|
| 508 |
+
// cadence, so display timing is decoupled from network arrival jitter.
|
| 509 |
+
ingestFrame(e.data.slice(8), blk, fps);
|
| 510 |
+
}else{
|
| 511 |
+
try{
|
| 512 |
+
const msg = JSON.parse(e.data);
|
| 513 |
+
if(msg.type === "status"){ overlayTitle.textContent = msg.message; }
|
| 514 |
+
else if(msg.type === "session_ended"){
|
| 515 |
+
setStatus("ended","Session ended"); setPlaying(false);
|
| 516 |
+
showOverlay("Session ended","GPU time limit reached — press Start world to continue.",false);
|
| 517 |
+
}else if(msg.type === "error"){
|
| 518 |
+
setStatus("error","Error"); setPlaying(false);
|
| 519 |
+
showOverlay("Something went wrong", msg.message || "Unknown error", false);
|
| 520 |
+
}
|
| 521 |
+
}catch{}
|
| 522 |
+
}
|
| 523 |
+
};
|
| 524 |
+
ws.onclose = ()=>{ if(state.playing) setTimeout(connectWS, 1000); };
|
| 525 |
+
state.ws = ws;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
// --- Control loop: send held keys ~10x/s ---
|
| 529 |
+
let ctrlTimer = null;
|
| 530 |
+
function startCtrlLoop(){
|
| 531 |
+
if(ctrlTimer) return;
|
| 532 |
+
ctrlTimer = setInterval(()=>{
|
| 533 |
+
if(state.ws?.readyState === WebSocket.OPEN && state.playing){
|
| 534 |
+
state.ws.send(JSON.stringify({
|
| 535 |
+
type:"control",
|
| 536 |
+
buttons: Array.from(state.pressed),
|
| 537 |
+
prompt: state.prompt,
|
| 538 |
+
}));
|
| 539 |
+
}
|
| 540 |
+
}, 100);
|
| 541 |
+
}
|
| 542 |
+
function stopCtrlLoop(){ if(ctrlTimer){ clearInterval(ctrlTimer); ctrlTimer = null; } }
|
| 543 |
+
|
| 544 |
+
function renderKeys(){
|
| 545 |
+
// Highlight the on-screen WASD + IJKL keys that are currently held.
|
| 546 |
+
keycaps.forEach(c=>c.classList.toggle("active", state.pressed.has(c.dataset.k)));
|
| 547 |
+
// Look/pan status badge reflects the held look keys (I J K L).
|
| 548 |
+
const look = Array.from(state.pressed).filter(k=>LOOK_KEYS.has(k));
|
| 549 |
+
const looking = look.length > 0;
|
| 550 |
+
lookBadge.classList.toggle("active", looking);
|
| 551 |
+
const LOOK_LABEL = { I:"up", K:"down", J:"left", L:"right" };
|
| 552 |
+
lookBadgeText.textContent = looking
|
| 553 |
+
? "Looking " + look.map(k=>LOOK_LABEL[k]).join(" + ")
|
| 554 |
+
: (state.capturing ? "Look ready" : "Look idle");
|
| 555 |
+
const held = Array.from(state.pressed);
|
| 556 |
+
activeTags.textContent = held.length ? "Active: " + held.join(" + ") : "";
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
// --- Keyboard capture: WASD (move) + IJKL (look/pan) ---
|
| 560 |
+
// Clicking the stage captures input; held keys are sent ~10x/s to the server.
|
| 561 |
+
function requestCapture(){
|
| 562 |
+
state.capturing = true;
|
| 563 |
+
stage.focus();
|
| 564 |
+
renderKeys();
|
| 565 |
+
}
|
| 566 |
+
stage.addEventListener("click", requestCapture);
|
| 567 |
+
|
| 568 |
+
document.addEventListener("keydown",(e)=>{
|
| 569 |
+
if(!state.capturing) return;
|
| 570 |
+
if(e.code === "Escape"){
|
| 571 |
+
state.capturing=false; state.pressed.clear();
|
| 572 |
+
renderKeys(); return;
|
| 573 |
+
}
|
| 574 |
+
const k = KEY_FROM_CODE[e.code];
|
| 575 |
+
if(k && (MOVE_KEYS.has(k) || LOOK_KEYS.has(k))){ e.preventDefault(); state.pressed.add(k); renderKeys(); }
|
| 576 |
+
});
|
| 577 |
+
document.addEventListener("keyup",(e)=>{
|
| 578 |
+
const k = KEY_FROM_CODE[e.code];
|
| 579 |
+
if(k){ state.pressed.delete(k); renderKeys(); }
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
promptEl.addEventListener("input", ()=>{ state.prompt = promptEl.value; });
|
| 583 |
+
|
| 584 |
+
// --- Start / Stop ---
|
| 585 |
+
async function startWorld(){
|
| 586 |
+
if(!state.seedPath) return;
|
| 587 |
+
state.prompt = promptEl.value;
|
| 588 |
+
await initClient();
|
| 589 |
+
setStatus("loading","Starting…");
|
| 590 |
+
showOverlay("Initializing GPU & world…","Requesting a ZeroGPU slot — first run can take a moment.", true);
|
| 591 |
+
startBtn.disabled = true;
|
| 592 |
+
|
| 593 |
+
connectWS();
|
| 594 |
+
startCtrlLoop();
|
| 595 |
+
try{
|
| 596 |
+
await state.client.predict("/start_game", {
|
| 597 |
+
session_id: state.sessionId,
|
| 598 |
+
seed_path: state.seedPath,
|
| 599 |
+
prompt: state.prompt || "",
|
| 600 |
+
seed: parseInt(seedEl.value || "42", 10),
|
| 601 |
+
});
|
| 602 |
+
// Auto-capture keyboard so the user can steer immediately.
|
| 603 |
+
state.capturing = true; stage.focus();
|
| 604 |
+
}catch(err){
|
| 605 |
+
console.error("start failed", err);
|
| 606 |
+
setStatus("error","Failed to start");
|
| 607 |
+
showOverlay("Failed to start", String(err?.message || err), false);
|
| 608 |
+
startBtn.disabled = false;
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
async function stopWorld(){
|
| 613 |
+
try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
|
| 614 |
+
if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
|
| 615 |
+
resetJitterBuffer();
|
| 616 |
+
stopCtrlLoop();
|
| 617 |
+
setPlaying(false);
|
| 618 |
+
state.pressed.clear(); renderKeys();
|
| 619 |
+
setStatus("idle","Idle");
|
| 620 |
+
showOverlay("Stopped","Press Start world to roll out again.", false);
|
| 621 |
+
refreshStartEnabled();
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
startBtn.addEventListener("click", startWorld);
|
| 625 |
+
stopBtn.addEventListener("click", stopWorld);
|
| 626 |
+
|
| 627 |
+
// --- Init ---
|
| 628 |
+
setStatus("idle","Idle");
|
| 629 |
+
state.prompt = promptEl.value;
|
| 630 |
+
loadPresets();
|
| 631 |
+
initClient().catch(console.error);
|
| 632 |
+
</script>
|
| 633 |
+
</body>
|
| 634 |
+
</html>
|
requirements.txt
CHANGED
|
@@ -3,3 +3,4 @@ git+https://github.com/huggingface/diffusers.git@ffee5d2d9ccf86db5c21190e223ea49
|
|
| 3 |
torch
|
| 4 |
transformers
|
| 5 |
ftfy
|
|
|
|
|
|
| 3 |
torch
|
| 4 |
transformers
|
| 5 |
ftfy
|
| 6 |
+
python-multipart
|