YiYiXu HF Staff commited on
Commit
4587be9
·
verified ·
1 Parent(s): 8388123

Live front-end: gradio.Server + WebSocket paced frame stream (shell from acvlab/abot-world-interactive), engine = pipe.stream(action_source=...)

Browse files
Files changed (4) hide show
  1. README.md +9 -5
  2. app.py +582 -97
  3. index.html +634 -0
  4. 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.5.1
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: the whole app is a
16
- `pipe.stream(action_source=...)` consumer the pipeline polls the held control once per generated block.
 
 
17
 
18
- Runs on ZeroGPU: one `@spaces.GPU` generator owns the whole session (the rollout keeps a K/V cache alive
19
- across blocks) and the held control reaches it through a queue. ~20 GB VRAM in bf16.
 
 
 
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
- import spaces # must come before torch: on ZeroGPU it patches CUDA so the model can be staged at import
 
 
2
 
3
- import multiprocessing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- REPO = "YiYiXu/ABot-World-0-5B-LF-Diffusers"
16
 
17
- pipe = ABotWorldStreamingBlocks().init_pipeline(REPO)
18
- pipe.load_components(dtype=torch.bfloat16)
19
- pipe.to("cuda") # on ZeroGPU this only packs the weights; they land on the GPU inside the @spaces.GPU call
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- CONTROLS = {
27
- "▲ forward (W)": [1, 0, 0, 0, 0, 0, 0, 0],
28
- "◀ left (A)": [0, 1, 0, 0, 0, 0, 0, 0],
29
- "▼ back (S)": [0, 0, 1, 0, 0, 0, 0, 0],
30
- "▶ right (D)": [0, 0, 0, 1, 0, 0, 0, 0],
31
- "cam (I)": [0, 0, 0, 0, 1, 0, 0, 0],
32
- "cam ◀ (J)": [0, 0, 0, 0, 0, 1, 0, 0],
33
- "cam ↓ (K)": [0, 0, 0, 0, 0, 0, 1, 0],
34
- "cam ▶ (L)": [0, 0, 0, 0, 0, 0, 0, 1],
35
- "· idle": [0] * 8,
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
- def action_source(block_index):
60
- nonlocal held
61
- while True:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  try:
63
- command = commands.get_nowait()
64
- except queue.Empty:
 
 
 
 
 
 
 
 
65
  break
66
- if command is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  return None
68
- held = command
69
- return held if block_index < max_blocks else None
70
-
71
- for event in pipe.stream(
72
- prompt=(prompt or DEFAULT_PROMPT).strip() or DEFAULT_PROMPT,
73
- image=image,
74
- action_source=action_source,
75
- generator=torch.Generator("cpu").manual_seed(42),
76
- ):
77
- if event.path != "denoise.rollout":
78
- continue
79
- frames = event.state.get("frames")
80
- yield (frames[-1] * 255).clip(0, 255).astype(np.uint8)
81
-
82
-
83
- def play(image, prompt, max_blocks):
84
- if not run_lock.acquire(blocking=False):
85
- raise gr.Error("A session is already running — stop it first.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  try:
87
- while True: # drop controls left over from the previous session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  try:
89
- commands.get_nowait()
 
 
 
 
 
 
 
 
 
 
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
- with gr.Blocks(title="ABot-World Interactive") as demo:
100
- gr.Markdown(
101
- """# ABot-World interactive world, Modular Diffusers
102
- Upload a scene, press Start, then hold a control: each ~1 s block is generated live by
103
- [ABot-World](https://huggingface.co/YiYiXu/ABot-World-0-5B-LF-Diffusers) conditioned on the held action.
104
- The whole app is one `pipe.stream(action_source=...)` loop — the pipeline polls your current input once
105
- per block ([huggingface/diffusers#14159](https://github.com/huggingface/diffusers/pull/14159))."""
106
- )
107
- with gr.Row():
108
- with gr.Column(scale=1):
109
- image = gr.Image(label="starting scene", type="pil", height=220)
110
- prompt = gr.Textbox(label="scene prompt", value=DEFAULT_PROMPT, lines=2)
111
- max_blocks = gr.Slider(label="max blocks (~seconds)", minimum=4, maximum=40, step=1, value=20)
112
- with gr.Row():
113
- start_button = gr.Button("Start", variant="primary")
114
- stop_button = gr.Button("Stop")
115
- gr.Examples(
116
- examples=[["examples/desert_valley.png"], ["examples/forest_stream.png"],
117
- ["examples/mountain_meadow.png"], ["examples/example.png"]],
118
- inputs=[image],
119
- )
120
- with gr.Column(scale=2):
121
- view = gr.Image(label="world", show_label=False, interactive=False)
122
- status = gr.Textbox(value="holding: ▲ forward (W)", show_label=False, container=False)
123
- with gr.Row():
124
- buttons = [gr.Button(name, size="sm") for name in CONTROLS]
125
-
126
- for button in buttons:
127
- button.click(hold, inputs=gr.State(button.value), outputs=status, api_name="hold")
128
- start_button.click(play, inputs=[image, prompt, max_blocks], outputs=view, api_name="play")
129
- stop_button.click(stop, outputs=status, api_name="stop")
130
-
131
- demo.launch(show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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