File size: 20,606 Bytes
36cdb93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93a8d76
 
 
 
36cdb93
 
 
 
efc5130
 
 
 
 
 
 
36cdb93
5bedd16
 
 
 
 
358cf00
36cdb93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f775403
36cdb93
 
 
 
 
 
 
93a8d76
36cdb93
 
 
 
 
 
 
 
 
 
93a8d76
 
 
 
 
 
 
 
 
36cdb93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8238d49
 
36cdb93
8238d49
92748f7
 
 
 
 
 
 
 
 
 
 
 
 
 
8238d49
92748f7
8238d49
 
 
 
92748f7
 
 
8238d49
 
 
3c2d353
 
 
 
 
 
 
8238d49
 
 
 
92748f7
8238d49
 
 
92748f7
8238d49
 
 
36cdb93
 
 
 
 
 
 
 
 
 
 
 
93a8d76
 
 
 
 
 
36cdb93
 
 
 
 
 
c6ab681
3aa8bab
 
 
 
 
36cdb93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3aa8bab
 
f775403
 
 
 
 
36cdb93
 
 
93a8d76
3aa8bab
36cdb93
 
 
 
 
93a8d76
36cdb93
3aa8bab
36cdb93
 
 
 
 
 
 
3aa8bab
 
 
 
93a8d76
 
 
 
 
 
 
 
36cdb93
 
 
b32da83
 
 
 
 
68f4fd7
3c2d353
93a8d76
36cdb93
d5bd942
 
 
 
 
36cdb93
 
 
d5bd942
36cdb93
d5bd942
 
 
 
 
 
 
36cdb93
d5bd942
 
 
 
36cdb93
d5bd942
36cdb93
 
 
 
3aa8bab
36cdb93
 
 
 
 
 
 
d5bd942
 
 
 
36cdb93
 
 
 
d5bd942
 
 
36cdb93
 
 
 
 
 
f310128
36cdb93
 
 
 
 
f310128
 
36cdb93
f310128
 
36cdb93
 
3aa8bab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ee5f0c
36cdb93
 
c6ab681
 
 
 
 
 
 
 
 
 
 
3aa8bab
c6ab681
 
3aa8bab
 
314fbfe
 
 
 
 
3aa8bab
314fbfe
 
 
c6ab681
 
 
 
b4efd73
 
 
 
c6ab681
 
 
627522e
 
3aa8bab
627522e
 
 
3aa8bab
 
 
 
36cdb93
 
2ee5f0c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
"""LiveWan on ZeroGPU: a streaming, steerable text-to-video demo.

This Space drives the project's own serving engine (`wanstreamer.serve.engine.Engine`)
rather than reimplementing the streaming maths. The engine opens a cached world,
extends it block by block with the distilled 1.3B student, and decodes each block
through a VAE whose causal-conv cache is kept alive across calls so the blocks join
without a seam. That is the same code path `livewan-serve` runs locally.

What ZeroGPU changes, and why the UI looks the way it does: a GPU worker is forked
per request and cannot be steered from outside while it runs, so the demo takes the
steer as a *schedule* -- "start in this world, swap the conditioning to this prompt
at t = N seconds" -- instead of a live button. The swap itself is exactly the live
one: `Engine.steer` replaces the cross-attention conditioning and leaves the K/V
cache in place, so the scene continues rather than cutting.

Steers can come from the project's 96-prompt bank or from free text, which umt5-xxl
encodes here. The bank is the conditioning every published number refers to; free
text is not numerically comparable to it, because umt5 embeddings vary slightly by
hardware, so the same string encoded on the training box is not the same tensor.
"""

import os

# Deliberately NOT `expandable_segments:True`. That is the usual fix for allocator
# trouble under transient spikes, but here it *causes* it: expandable segments grow
# through the CUDA VMM path, and the first growth inside a ZeroGPU worker aborts with
#   NVML_SUCCESS == r INTERNAL ASSERT FAILED ... CUDACachingAllocator.cpp
# on an allocation of ~49 MB, while the same allocation succeeds with the default
# allocator. Set it explicitly so a platform default cannot turn it back on.
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:False"

# The SDPA backend order that wan21_patches/modules/attention.py pins (cuDNN first)
# is left alone. Forcing EFFICIENT_ATTENTION here was tried, on the theory that this
# sm_120 / torch 2.11 runtime was falling through to the MATH backend; it changed the
# peak not at all (44.2 GB either way), so the backend is not the problem and the
# project's own measured order stands.

import queue
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path

import spaces  # must precede torch: it patches torch.cuda.* for module-scope loading
import torch

import cv2
import gradio as gr
import imageio.v2 as imageio
import numpy as np
from huggingface_hub import snapshot_download

APP = Path(__file__).resolve().parent
ASSETS = APP / "assets"
BASE_DIR = APP / "wan21_13b"
WAN_REPO = APP / "wan21_repo"
WORLDS_DIR = APP / "generated_worlds"

LIVEWAN_REPO = "JonathanColetti/LiveWan"
BASE_REPO = "Wan-AI/Wan2.1-T2V-1.3B"
GITHUB = "https://github.com/JonathanColetti/LiveWan"

FPS = 16
BLOCK_SECONDS = 0.75  # 3 latent frames -> 12 pixel frames at 16 fps
NO_STEER = "don't steer, stay on the opening prompt"
STALL_SECONDS = 45  # a block takes ~1.0 s here; this only trips on a real fault

# ---------------------------------------------------------------- bootstrap


def fetch_weights():
    """Pull the student, the prompt bank, the four worlds and the Wan2.1 base.

    The optimiser shards (17.7 GB) are for resuming training and are skipped.
    """
    snapshot_download(
        LIVEWAN_REPO, local_dir=str(ASSETS),
        allow_patterns=["checkpoints/t14b_b64/latest.pt", "data/prompts.pt",
                        "out/world_p*.pt"])
    # Wan2.1_VAE.pth decodes every block. The base transformer is the scaffold the
    # student's weights are loaded into (see Engine._load_student).
    snapshot_download(
        BASE_REPO, local_dir=str(BASE_DIR),
        allow_patterns=["Wan2.1_VAE.pth", "config.json",
                        "diffusion_pytorch_model.safetensors",
                        # umt5-xxl encodes free-text steers; the bank covers the
                        # rest. 11 GB, and the reason this Space downloads 29 GB.
                        "models_t5_umt5-xxl-enc-bf16.pth", "google/umt5-xxl/*"])
    # Engine.load() prefers a local `umt5-tokenizer/` and otherwise resolves the hub
    # id, which would mean a network fetch inside every cold GPU worker.
    tok = BASE_DIR / "umt5-tokenizer"
    if not tok.exists():
        tok.symlink_to(BASE_DIR / "google/umt5-xxl", target_is_directory=True)


def install_wan_reference_code():
    """Clone Wan2.1 and apply the project's two patches, then put it on sys.path.

    `attention.py` replaces upstream's `assert FLASH_ATTN_2_AVAILABLE` with an SDPA
    fallback that keeps q_lens/k_lens; `configs/__init__.py` adds the 640x368 size
    entries this project streams at. Both are the same files setup.sh copies.

    `wan/__init__.py` is emptied on purpose. Upstream's eagerly imports the T2V/I2V/
    VACE pipelines, which drag in dashscope, xfuser and `torch.cuda.amp` wrappers this
    demo never calls; only `wan.configs` and `wan.modules` are needed.
    """
    if not WAN_REPO.exists():
        subprocess.run(["git", "clone", "-q", "--depth", "1",
                        "https://github.com/Wan-Video/Wan2.1", str(WAN_REPO)],
                       check=True)
    shutil.copy(APP / "wan21_patches/modules/attention.py",
                WAN_REPO / "wan/modules/attention.py")
    shutil.copy(APP / "wan21_patches/configs/__init__.py",
                WAN_REPO / "wan/configs/__init__.py")
    (WAN_REPO / "wan/__init__.py").write_text(
        "# emptied by the LiveWan Space: only wan.configs and wan.modules are used\n")
    if str(WAN_REPO) not in sys.path:
        sys.path.insert(0, str(WAN_REPO))


def ensure_cuda_amp_shim():
    """`wan.modules.model` imports `torch.cuda.amp`, removed in some torch builds."""
    try:
        import torch.cuda.amp  # noqa: F401
    except ImportError:
        import types
        shim = types.ModuleType("torch.cuda.amp")
        shim.autocast = lambda *a, **k: torch.amp.autocast("cuda", *a, **k)
        shim.custom_fwd = torch.amp.custom_fwd
        shim.custom_bwd = torch.amp.custom_bwd
        sys.modules["torch.cuda.amp"] = shim
        torch.cuda.amp = shim


print("[boot] downloading weights", flush=True)
fetch_weights()
install_wan_reference_code()
ensure_cuda_amp_shim()

from wanstreamer.serve.engine import Engine  # noqa: E402  (needs sys.path above)
from wanstreamer.serve.streamdecode import StreamingVAEDecoder  # noqa: E402
from wanstreamer.stream import FewStepStreamer  # noqa: E402


def harden_worker():
    """Run block generation under `no_grad`, and log what a failure saw.

    `FewStepStreamer.generate_block` carries no `torch.no_grad()` of its own --
    unlike `set_world`, `stream` and the VAE's `decode`, which all do. Locally that
    costs nothing, because `Engine._load_student` calls `requires_grad_(False)` on
    every parameter, so no graph is ever built. Here the weights are loaded in the
    main process, packed to disk by ZeroGPU and streamed into a forked worker, and
    autograd ends up live again: all 30 layers of each of the three forwards a block
    does are retained, and one block peaks at 44.2 GB against a 47.4 GB slice. The
    no-grad forwards (the world prime, the decode) were the ones that worked.

    `Engine._run` also swallows tracebacks, keeping only `type: message`, which is
    right for a status bar and useless for a thread inside a forked worker.
    """
    def wrap(cls, name, no_grad=False):
        inner = getattr(cls, name)

        def outer(self, *a, **k):
            try:
                if no_grad:
                    with torch.no_grad():
                        return inner(self, *a, **k)
                return inner(self, *a, **k)
            except Exception:
                import traceback
                free, total = torch.cuda.mem_get_info()
                print(f"[fail] {name}: allocated="
                      f"{torch.cuda.memory_allocated() / 2**30:.1f}G peak="
                      f"{torch.cuda.max_memory_allocated() / 2**30:.1f}G reserved="
                      f"{torch.cuda.memory_reserved() / 2**30:.1f}G "
                      f"device_free={free / 2**30:.1f}G/{total / 2**30:.1f}G",
                      flush=True)
                traceback.print_exc()
                raise
        setattr(cls, name, outer)

    wrap(FewStepStreamer, "generate_block", no_grad=True)
    wrap(StreamingVAEDecoder, "decode")


harden_worker()

print(f"[boot] PYTORCH_CUDA_ALLOC_CONF="
      f"{os.environ.get('PYTORCH_CUDA_ALLOC_CONF')!r}", flush=True)
print("[boot] loading the engine", flush=True)
engine = Engine(
    assets=ASSETS,
    wan_repo=WAN_REPO,
    base_dir=BASE_DIR,
    weights=ASSETS / "checkpoints/t14b_b64/latest.pt",
    device="cuda",
    worlds_dir=WORLDS_DIR,
    allow_worldgen=False,  # the 5.7 GB base is the load scaffold, not a second model
    compile_vae=False,     # torch.compile cannot run in a ZeroGPU worker
)
engine.load(progress=lambda m: print(f"[boot] {m}", flush=True))
# The engine loads umt5-xxl lazily, on the first free-text prompt. That is right for
# a long-lived local server and wrong here: a lazy load happens inside a forked GPU
# worker and every cold worker pays it. Load it now, at module scope, so ZeroGPU
# packs it to disk with the rest and streams it in.
print("[boot] loading umt5-xxl for free text", flush=True)
engine.encoder.load()

INFO = engine.info()
PROMPTS = {f"{p['idx']:>2} · {p['text']}": p["idx"] for p in INFO["prompts"]}
WORLDS = {f"world {w['idx']} · {w['prompt'][:70]}": w["idx"] for w in INFO["worlds"]}
WORLD_LABELS = list(WORLDS)
PROMPT_LABELS = list(PROMPTS)
W = {i: lbl for lbl, i in WORLDS.items()}   # by world id, for the default
# Themes are what the local UI uses to warn about a steer that will smear. The four
# shipped worlds are named by their own bank prompt, so the world id is a prompt id.
from wanstreamer.serve.conditioning import theme_of  # noqa: E402
PROMPT_THEME = {p["idx"]: p["theme"] for p in INFO["prompts"]}
WORLD_THEME = {w["idx"]: theme_of(w["idx"]) for w in INFO["worlds"]}
print(f"[boot] ready: step {INFO['step']}, {len(PROMPTS)} prompts, "
      f"{len(WORLDS)} worlds", flush=True)

# ------------------------------------------------------------------- render


def hud(stats, wall):
    """The numbers the browser demo puts along the bottom of the stream."""
    total = stats.get("total_s") or 0.0
    rt = (BLOCK_SECONDS / total) if total else 0.0
    return (
        f"**{stats['seconds']:.2f} s** of video · {stats['blocks']} blocks · "
        f"latent frames **{stats['latent_frames']}/{stats['latent_frames_max']}**\n\n"
        f"block **{total * 1000:.0f} ms** "
        f"(generate {stats.get('gen_s', 0) * 1000:.0f} ms, "
        f"decode {stats.get('decode_s', 0) * 1000:.0f} ms) · "
        f"**{rt:.2f}x real time** · K/V cache **{stats['kv_mb']:.0f} MB** · "
        f"{wall:.1f} s on the GPU"
    )


def write_mp4(frames):
    path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
    with imageio.get_writer(path, fps=FPS, codec="libx264", quality=8,
                            macro_block_size=1,
                            ffmpeg_params=["-pix_fmt", "yuv420p"]) as w:
        for f in frames:
            w.append_data(f)
    return path


def _duration(world, steer_to, steer_text, steer_at, crossfade, seconds, seed,
              *args, **kwargs):
    # Measured: ~12 s to open a world (load, prime the cache, decode its 81 frames)
    # and ~1.02 s per block, a block being 0.75 s of video. Declared tight on purpose:
    # ZeroGPU compares the request against the visitor's remaining quota, not the
    # actual runtime, so a padded number locks people out for no reason.
    return int(min(200, 30 + float(seconds) * 1.8))


@spaces.GPU(duration=_duration)
def run(world: str, steer_to: str, steer_text: str, steer_at: float,
        crossfade: int, seconds: float, seed: int):
    """Stream video from a cached world, optionally swapping the prompt mid-stream.

    Args:
        world: which of the four shipped worlds to open the stream on.
        steer_to: a prompt from the 96-prompt bank to swap to, or the no-steer option.
        steer_text: free text to swap to instead; takes precedence over steer_to.
        steer_at: seconds into the clip at which to swap the conditioning.
        crossfade: blocks to interpolate the conditioning over; 0 swaps instantly.
        seconds: how much video to generate, at 16 fps.
        seed: RNG seed for the block sampler.

    Yields:
        (preview frame, HUD line, finished mp4) — the mp4 only on the last yield.
    """
    total_frames = int(float(seconds) * FPS)
    # A swap at or past the end of the clip would never fire and the run would look
    # like the control did nothing. Keep it at least two seconds from the end so the
    # result of the swap is actually visible.
    steer_frame = int(min(float(steer_at), max(1.0, float(seconds) - 2.0)) * FPS)

    text = (steer_text or "").strip()
    if text:
        steer_with = {"text": text}
    elif steer_to and steer_to != NO_STEER:
        steer_with = {"idx": PROMPTS[steer_to]}
    else:
        steer_with = None

    t0 = time.perf_counter()
    engine.start(world=WORLDS[world], seed=int(seed))
    # Opening a world decodes all 81 of its pixel frames in one call, the largest
    # allocation in a run, and leaves the allocator holding ~17 GB the block loop has
    # no use for (reserved 30.5 G against 13.5 G allocated). Handing it back is not
    # what fixed the OOM this Space started with -- that was autograd, see
    # `harden_worker` -- but there is no reason to sit on it either.
    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    frames, pending_steer, last_push = [], steer_with is not None, 0.0

    # The worker runs in a thread and reports failure by setting status.state rather
    # than raising here, so poll it between frames: a long blocking get() would spend
    # the whole GPU reservation waiting on a stream that is already dead.
    ended = ""
    last_frame_at = time.perf_counter()
    try:
        while len(frames) < total_frames:
            try:
                jpg = engine.frames.get(timeout=0.5)
            except queue.Empty:
                state = engine.status.state
                if state == "error":
                    raise gr.Error(f"engine error: {engine.status.error}")
                if state != "streaming":
                    # The worker ends the stream itself at the 1024-latent-frame
                    # RoPE ceiling and puts the reason in `detail`.
                    ended = engine.status.detail
                    break
                if time.perf_counter() - last_frame_at > STALL_SECONDS:
                    raise gr.Error(
                        f"no block in {STALL_SECONDS} s (state={state})")
                continue

            last_frame_at = time.perf_counter()
            frames.append(cv2.imdecode(np.frombuffer(jpg, np.uint8),
                                       cv2.IMREAD_COLOR)[:, :, ::-1])

            if pending_steer and len(frames) >= steer_frame:
                engine.steer(crossfade=int(crossfade), **steer_with)
                pending_steer = False

            now = time.perf_counter()
            if now - last_push > 0.12:
                last_push = now
                yield frames[-1], hud(engine.stats(), now - t0), None
    finally:
        stats = engine.stats()
        print(f"[run] {len(frames)} frames, {stats['blocks']} blocks, "
              f"state={engine.status.state!r} detail={engine.status.detail!r} "
              f"error={engine.status.error!r}", flush=True)
        engine.stop()

    if not frames:
        raise gr.Error("no frames were produced")
    line = hud(stats, time.perf_counter() - t0)
    if ended:
        line += f"\n\n_{ended}_"
    yield frames[-1], line, write_mp4(frames)


# ----------------------------------------------------------------------- ui

CSS = """
#col-container { max-width: 1150px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

INTRO = f"""# LiveWan

Streaming text-to-video you can steer while it runs. Swap the prompt partway through
and the scene continues instead of cutting.

Running on shared ZeroGPU hardware, so about 0.7x real time here; a dedicated GPU
does about 2.7x. [Code]({GITHUB}) · [Weights](https://huggingface.co/{LIVEWAN_REPO})
"""

def steer_warning(world, steer_to, steer_text):
    """The same warning the local UI shows before a steer that will smear.

    Cross-theme steers ask the model to reconcile a K/V cache full of one kind of
    scene with a prompt describing another, and it smears rather than resolving.
    Free text gets no theme, so it cannot be checked.
    """
    if (steer_text or "").strip() or not steer_to or steer_to == NO_STEER:
        return gr.update(visible=False)
    wt = WORLD_THEME.get(WORLDS[world])
    pt = PROMPT_THEME.get(PROMPTS[steer_to])
    if not wt or not pt or wt == pt:
        return gr.update(visible=False)
    return gr.update(visible=True, value=(
        f"⚠️ {wt.lower()} to {pt.lower()} is a jump across themes, so expect the "
        f"picture to smear rather than resolve. Raise the crossfade to soften it, "
        f"or steer somewhere closer to where you started."))


def bound_swap(seconds, steer_at):
    """A swap must land far enough before the end to be worth watching."""
    hi = max(1.0, float(seconds) - 2.0)
    return gr.update(maximum=hi, value=min(float(steer_at), hi))


with gr.Blocks(title="LiveWan") as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(INTRO)

        with gr.Row(equal_height=False):
            with gr.Column(scale=2):
                world = gr.Dropdown(WORLD_LABELS, value=W[60],
                                    label="Open on world")
                steer_to = gr.Dropdown([NO_STEER] + PROMPT_LABELS, value=NO_STEER,
                                       label="Steer to", info="From the prompt bank")
                steer_text = gr.Textbox(
                    label="Or steer to your own text", lines=2,
                    placeholder="A lighthouse in a storm",
                    info="Encoded with umt5-xxl. Overrides the selection above.")
                warning = gr.Markdown(visible=False)
                seconds = gr.Slider(5, 30, value=15, step=1,
                                    label="Generate (seconds of video)")
                steer_at = gr.Slider(1, 13, value=6, step=0.5,
                                     label="Swap at (seconds in)")
                # The local UI defaults this to 0, which is right when a human is
                # watching and can react. For a one-click demo it is the wrong
                # default: at 0 a waterfall steered to an aurora has collapsed into
                # blocking by 14 s, and at 8 the scene is still standing.
                crossfade = gr.Slider(0, 12, value=6, step=1,
                                      label="Crossfade (blocks)",
                                      info="Blending the swap over a few blocks "
                                           "holds the scene together. 0 swaps "
                                           "instantly, which is harsher.")
                seed = gr.Number(value=0, precision=0, label="Seed")
                run_btn = gr.Button("Stream", variant="primary", size="lg")

            with gr.Column(scale=3):
                # Both at the stream's native 640x368: the live view is the point of
                # the demo, so it is not the small one.
                preview = gr.Image(label="Live, as each block lands", height=368)
                stats = gr.Markdown()
                video = gr.Video(label="The clip, at 16 fps", autoplay=True,
                                 height=368)

    # api_name=False keeps these out of the API tab and the MCP tool list; they are
    # UI wiring, not endpoints. `run` is the only thing worth calling from outside.
    for control in (world, steer_to, steer_text):
        control.change(steer_warning, [world, steer_to, steer_text], warning,
                       api_name=False)
    seconds.change(bound_swap, [seconds, steer_at], steer_at, api_name=False)

    run_btn.click(run,
                  inputs=[world, steer_to, steer_text, steer_at, crossfade,
                          seconds, seed],
                  outputs=[preview, stats, video], concurrency_limit=1)

demo.queue(max_size=12).launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)