Spaces:
Running on Zero
Running on Zero
| """Session engine for the browser demo. | |
| This is a thin serving layer over the project's own inference core: the student is | |
| rolled out by `wanstreamer.stream.FewStepStreamer`, which is the code the checkpoint | |
| was distilled and measured under. Nothing here reimplements the streaming maths -- | |
| the block loop, the block-causal K/V cache, the renoise sampler and `latent_norm` | |
| all live in the core, and this module only drives them and turns latents into JPEG. | |
| One GPU, one stream. A background worker generates blocks and pushes frames into a | |
| bounded queue. The queue is deliberately short (~1.5 s) because it *is* the steering | |
| latency (anything a viewer has already been sent cannot be steered any more). | |
| Two controls, doing genuinely different things: | |
| steer -- swap the cross-attention conditioning, keep the K/V cache. The scene | |
| continues. Cheap, instant, no reload. | |
| scene -- tear the stream down and reopen from another world. A cut. | |
| """ | |
| import json | |
| import queue | |
| import threading | |
| import time | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| import torch | |
| from ..stream import FewStepStreamer | |
| from .paths import WORLDS_DIR | |
| from .streamdecode import StreamingVAEDecoder | |
| from .conditioning import PromptBank, TextEncoder | |
| from .worldgen import WorldGenerator | |
| WORLDS = [0, 44, 60, 82] # the four shipped caches; generated ones get ids from 1000 | |
| GENERATED_BASE_ID = 1000 | |
| FPS = 16 | |
| # Defaults matching the published command in the model card. `shift=1.0` is the | |
| # uniform few-step spacing the student was distilled under (see | |
| # wanstreamer.stream.few_step_schedule); `window` is in LATENT FRAMES, not blocks. | |
| DEFAULTS = dict(block=3, steps=2, window=6, latent_norm=1.0, shift=1.0, | |
| sampler="renoise") | |
| def _encode_jpeg(arr, quality=88): | |
| import cv2 | |
| ok, buf = cv2.imencode(".jpg", arr[:, :, ::-1], [cv2.IMWRITE_JPEG_QUALITY, quality]) | |
| if not ok: | |
| raise RuntimeError("jpeg encode failed") | |
| return buf.tobytes() | |
| class StreamExhausted(RuntimeError): | |
| pass | |
| class Status: | |
| state: str = "idle" # idle | loading | generating | streaming | error | |
| detail: str = "" | |
| world: int | None = None | |
| prompt: str = "" | |
| prompt_source: str = "" # "bank" | "text" | |
| stats: dict = field(default_factory=dict) | |
| error: str = "" | |
| progress: float | None = None # 0..1 while generating a world | |
| class Engine: | |
| def __init__(self, assets, wan_repo, base_dir, weights=None, device="cuda", | |
| jpeg_quality=88, buffer_seconds=1.5, worlds_dir=None, | |
| allow_worldgen=True, size=(640, 368), compile_vae=True): | |
| self.assets = Path(assets) | |
| self.wan_repo = Path(wan_repo) | |
| self.base_dir = Path(base_dir) | |
| self.weights = Path(weights or self.assets / "checkpoints/t14b_b64/latest.pt") | |
| self.worlds_dir = Path(worlds_dir or WORLDS_DIR) | |
| self.allow_worldgen = allow_worldgen | |
| self.size = size | |
| self.device = device | |
| self.jpeg_quality = jpeg_quality | |
| self.compile_vae = compile_vae | |
| self.maxframes = int(buffer_seconds * FPS) | |
| self.worlds = {} | |
| self.worldgen = None | |
| self.status = Status() | |
| self.lock = threading.Lock() | |
| self._genlock = threading.Lock() | |
| self.frames = queue.Queue(maxsize=self.maxframes) | |
| self._worker = None | |
| self._stop = threading.Event() | |
| self._pending_prompt = None | |
| self.model = self.decoder = self.streamer = None | |
| self._cur_emb = None | |
| self.bank = self.encoder = None | |
| self.step = None | |
| self.cfg = None | |
| self._loaded = False | |
| self._timings = {} | |
| self._frames_emitted = 0 | |
| self._blocks = 0 | |
| # ------------------------------------------------------------------ load | |
| def load(self, progress=None): | |
| def say(msg): | |
| self.status.state, self.status.detail = "loading", msg | |
| if progress: | |
| progress(msg) | |
| say("reading the prompt bank") | |
| self.bank = PromptBank(self.assets / "data/prompts.pt") | |
| say(f"loading the student ({self.weights.name})") | |
| self.model, self.cfg, self.step = self._load_student() | |
| say("loading the Wan2.1 VAE") | |
| torch.backends.cudnn.benchmark = True | |
| self.decoder = StreamingVAEDecoder( | |
| self.base_dir / "Wan2.1_VAE.pth", self.wan_repo, self.device | |
| ) | |
| if self.compile_vae: | |
| say("compiling the decoder (one-off, ~40 s)") | |
| try: | |
| import torch._dynamo as dynamo | |
| dynamo.config.recompile_limit = 64 | |
| self.decoder.model.decoder = torch.compile( | |
| self.decoder.model.decoder, dynamic=False) | |
| w = torch.load(self.assets / "out/world_p60.pt", map_location="cpu", | |
| weights_only=False) | |
| self.decoder.decode(w["latents"][:, :6].to(self.device)) | |
| self.decoder.reset() | |
| except Exception as e: # an optimisation, never a requirement | |
| self.status.detail = f"decoder compile skipped: {e}" | |
| tok = self.base_dir / "umt5-tokenizer" | |
| self.encoder = TextEncoder( | |
| self.base_dir / "models_t5_umt5-xxl-enc-bf16.pth", | |
| tok if tok.exists() else "google/umt5-xxl", self.wan_repo, self.device, | |
| ) | |
| if self.allow_worldgen: | |
| self.worldgen = WorldGenerator( | |
| self.base_dir / "diffusion_pytorch_model.safetensors", | |
| self.cfg, self.device, | |
| ) | |
| self._index_worlds() | |
| self._loaded = True | |
| self.status.state, self.status.detail = "idle", "ready" | |
| def _load_student(self): | |
| """Build the stock WanModel and load the distilled weights into it. | |
| Mirrors scripts/demo.py: a checkpoint that silently half-loaded would report | |
| base-model quality as if it were trained, so every parameter is checked. | |
| """ | |
| from wan.configs import WAN_CONFIGS | |
| from wan.modules.model import WanModel | |
| from safetensors.torch import load_file | |
| cfg = WAN_CONFIGS["t2v-1.3B"] | |
| m = WanModel(dim=cfg.dim, ffn_dim=cfg.ffn_dim, freq_dim=cfg.freq_dim, | |
| num_heads=cfg.num_heads, num_layers=cfg.num_layers, | |
| window_size=cfg.window_size, qk_norm=True, | |
| cross_attn_norm=True, eps=1e-6) | |
| m.load_state_dict( | |
| load_file(str(self.base_dir / "diffusion_pytorch_model.safetensors")), | |
| strict=True) | |
| sd = torch.load(self.weights, map_location="cpu", weights_only=False) | |
| src = sd.get("model", sd) | |
| res = m.load_state_dict(src, strict=False) | |
| got = {n for n, _ in m.named_parameters()} - set(res.missing_keys) | |
| if len(got) != len(list(m.named_parameters())) or res.unexpected_keys: | |
| raise RuntimeError( | |
| f"bad checkpoint load: {len(res.missing_keys)} missing, " | |
| f"{len(res.unexpected_keys)} unexpected") | |
| return m.to(self.device).eval().requires_grad_(False), cfg, sd.get("step") | |
| # ---------------------------------------------------------------- worlds | |
| def _index_worlds(self): | |
| self.worlds = { | |
| w: {"path": self.assets / f"out/world_p{w}.pt", "prompt": self.bank.texts[w], | |
| "generated": False, "seconds": None} | |
| for w in WORLDS | |
| } | |
| self.worlds_dir.mkdir(parents=True, exist_ok=True) | |
| for meta_path in sorted(self.worlds_dir.glob("world_*.json")): | |
| try: | |
| meta = json.loads(meta_path.read_text()) | |
| pt = meta_path.with_suffix(".pt") | |
| if pt.exists(): | |
| self.worlds[int(meta["id"])] = { | |
| "path": pt, "prompt": meta.get("prompt", ""), | |
| "generated": True, "seconds": meta.get("seconds")} | |
| except Exception: | |
| continue # a half-written world should not stop the server booting | |
| def _next_world_id(self): | |
| used = [i for i in self.worlds if i >= GENERATED_BASE_ID] | |
| return max(used) + 1 if used else GENERATED_BASE_ID | |
| def worldgen_available(self): | |
| return bool(self.worldgen and self.worldgen.available) | |
| def generate_world(self, text=None, idx=None, steps=30, seed=0, guide=5.0): | |
| """Make a new opening world from text (or a bank prompt) with the base model. | |
| Slow (~52 s at 30 steps) and the only non-real-time step in the project. | |
| """ | |
| if not self.worldgen_available: | |
| raise RuntimeError( | |
| "world generation is unavailable — the Wan2.1 base transformer " | |
| "(diffusion_pytorch_model.safetensors) is not present") | |
| if not self._genlock.acquire(blocking=False): | |
| raise RuntimeError("already generating a world") | |
| try: | |
| self._stop_worker() | |
| pos, label, source = self.resolve_prompt(idx, text) | |
| self.status = Status(state="generating", detail="encoding the prompt", | |
| prompt=label, prompt_source=source, progress=0.0) | |
| def on_step(i, n): | |
| self.status.progress = i / n | |
| self.status.detail = f"denoising the world — step {i} of {n}" | |
| lat, secs = self.worldgen.generate( | |
| pos, self.bank.neg.float(), size=self.size, steps=steps, seed=seed, | |
| guide=guide, progress=on_step) | |
| self.status.detail, self.status.progress = "decoding", 1.0 | |
| self.decoder.reset() | |
| pixels = self.decoder.decode(lat) | |
| self.decoder.reset() | |
| wid = self._next_world_id() | |
| path = self.worlds_dir / f"world_{wid}.pt" | |
| # keep the conditioning with the world: reopening needs no text encoder | |
| torch.save({"latents": lat.cpu(), "pixels": pixels, | |
| "prompt_emb": pos.cpu(), "base_seconds": secs}, path) | |
| path.with_suffix(".json").write_text(json.dumps( | |
| {"id": wid, "prompt": label, "steps": steps, "seed": seed, | |
| "guide": guide, "seconds": round(secs, 1)}, indent=1)) | |
| self.worlds[wid] = {"path": path, "prompt": label, "generated": True, | |
| "seconds": round(secs, 1)} | |
| self.status = Status(state="idle", detail="world ready", prompt=label, | |
| prompt_source=source) | |
| return wid | |
| except Exception as e: | |
| self.status = Status(state="error", error=f"{type(e).__name__}: {e}") | |
| raise | |
| finally: | |
| self._genlock.release() | |
| def delete_world(self, wid): | |
| w = self.worlds.get(int(wid)) | |
| if not w or not w["generated"]: | |
| raise ValueError("only generated worlds can be deleted") | |
| Path(w["path"]).unlink(missing_ok=True) | |
| Path(w["path"]).with_suffix(".json").unlink(missing_ok=True) | |
| self.worlds.pop(int(wid), None) | |
| def resolve_prompt(self, idx=None, text=None): | |
| """-> (embedding [1, 512, 4096], label, source)""" | |
| if text: | |
| text = text.strip() | |
| if not text: | |
| raise ValueError("empty prompt") | |
| return self.encoder.encode(text), text, "text" | |
| if idx is None: | |
| raise ValueError("need a prompt index or text") | |
| idx = int(idx) | |
| if not 0 <= idx < len(self.bank): | |
| extra = "" | |
| if idx >= GENERATED_BASE_ID: | |
| extra = (f" — {idx} looks like a generated world id, not a prompt. " | |
| "Generated worlds carry their own conditioning: pass no " | |
| "prompt_idx/prompt_text and it will be used.") | |
| raise ValueError(f"prompt index must be 0-{len(self.bank)-1}{extra}") | |
| return self.bank.embedding(idx), self.bank.texts[idx], "bank" | |
| def _set_text(self, emb): | |
| """Push raw umt5 conditioning into the streamer, embedded as the core wants.""" | |
| emb = emb.to(self.device) | |
| with torch.amp.autocast("cuda", enabled=False): | |
| ctx = self.model.text_embedding(emb.float()) | |
| self.streamer.set_text(ctx) | |
| self._cur_emb = emb # kept so a crossfade can interpolate from it | |
| def start(self, world, idx=None, text=None, seed=0, steps=None, block=None, | |
| window=None, latent_norm=None, shift=None, sampler=None): | |
| if not self._loaded: | |
| raise RuntimeError("engine not loaded") | |
| world = int(world) | |
| entry = self.worlds.get(world) | |
| if entry is None: | |
| raise ValueError(f"unknown world {world}; have {sorted(self.worlds)}") | |
| block = block or DEFAULTS["block"] | |
| steps = steps or DEFAULTS["steps"] | |
| window = window or DEFAULTS["window"] | |
| shift = DEFAULTS["shift"] if shift is None else shift | |
| sampler = sampler or DEFAULTS["sampler"] | |
| latent_norm = DEFAULTS["latent_norm"] if latent_norm is None else latent_norm | |
| with self.lock: | |
| self._stop_worker() | |
| self.status = Status(state="loading", detail="opening the world", world=world) | |
| w = torch.load(entry["path"], map_location="cpu", weights_only=False) | |
| emb = label = source = None | |
| if idx is None and text is None: | |
| if not entry["generated"]: | |
| idx = world | |
| elif w.get("prompt_emb") is not None: | |
| emb, label, source = w["prompt_emb"].float(), entry["prompt"], "text" | |
| else: | |
| text = entry["prompt"] | |
| if emb is None: | |
| emb, label, source = self.resolve_prompt(idx, text) | |
| self.status.prompt, self.status.prompt_source = label, source | |
| world_lat = w["latents"] | |
| nw = world_lat.shape[1] | |
| self.streamer = None | |
| torch.cuda.empty_cache() | |
| self.streamer = FewStepStreamer( | |
| self.model, width=self.size[0], height=self.size[1], | |
| max_frames=1024, device=self.device, dtype=self.cfg.param_dtype, | |
| window_frames=window, | |
| # the world's K/V is pinned; the window bounds only the events after it | |
| cache_frames=nw + window + block + 2, | |
| block_frames=block, num_steps=steps, shift=shift, sampler=sampler, | |
| ) | |
| self.streamer.latent_norm = latent_norm | |
| self._set_text(emb) | |
| self.decoder.reset() | |
| self._frames_emitted = self._blocks = 0 | |
| self._pending_prompt = None | |
| self._gen = torch.Generator(device=self.device).manual_seed(int(seed)) | |
| t0 = time.time() | |
| self.streamer.set_world(world_lat.to(self.device, torch.float32)) | |
| pixels = self.decoder.decode(world_lat.to(self.device, torch.float32)) | |
| self._timings = {"start_s": round(time.time() - t0, 3)} | |
| self._frames_emitted = pixels.shape[0] | |
| self._stop.clear() | |
| self._worker = threading.Thread(target=self._run, args=(pixels,), | |
| daemon=True, name="livewan-worker") | |
| self._worker.start() | |
| self.status.state, self.status.detail = "streaming", "" | |
| return self.status | |
| def steer(self, idx=None, text=None, crossfade=0): | |
| if self.streamer is None or self.status.state != "streaming": | |
| raise RuntimeError("no stream is running") | |
| emb, label, source = self.resolve_prompt(idx, text) | |
| self._pending_prompt = (emb, int(crossfade)) | |
| self.status.prompt, self.status.prompt_source = label, source | |
| return self.status | |
| def stop(self): | |
| with self.lock: | |
| self._stop_worker() | |
| self.status = Status(state="idle", detail="stopped") | |
| def _stop_worker(self): | |
| self._stop.set() | |
| if self._worker and self._worker.is_alive(): | |
| self._worker.join(timeout=15) | |
| self._worker = None | |
| self._drain() | |
| def _drain(self): | |
| try: | |
| while True: | |
| self.frames.get_nowait() | |
| except queue.Empty: | |
| pass | |
| def _push(self, pixels): | |
| arr = pixels.numpy() | |
| for i in range(arr.shape[0]): | |
| data = _encode_jpeg(arr[i], self.jpeg_quality) | |
| while not self._stop.is_set(): | |
| try: | |
| self.frames.put(data, timeout=0.25) | |
| break | |
| except queue.Full: | |
| continue | |
| if self._stop.is_set(): | |
| return | |
| def _run(self, world_pixels): | |
| blend = None | |
| try: | |
| self._push(world_pixels) | |
| while not self._stop.is_set(): | |
| if self._pending_prompt is not None: | |
| emb, xf = self._pending_prompt | |
| self._pending_prompt = None | |
| if xf and self._cur_emb is not None: | |
| blend = [self._cur_emb.clone(), emb.to(self.device), 0, xf] | |
| else: | |
| blend = None | |
| self._set_text(emb) | |
| st = self.streamer | |
| if st.n_world + st.n_frames + st.block_frames > st.max_frames: | |
| raise StreamExhausted( | |
| f"stream reached the {st.max_frames}-latent-frame RoPE " | |
| "ceiling; start a new stream") | |
| t0 = time.time() | |
| z = st.generate_block(generator=self._gen) | |
| t_gen = time.time() | |
| pixels = self.decoder.decode(z[0].float()) | |
| self._blocks += 1 | |
| self._frames_emitted += pixels.shape[0] | |
| self._timings = { | |
| "gen_s": round(t_gen - t0, 4), | |
| "decode_s": round(time.time() - t_gen, 4), | |
| "total_s": round(time.time() - t0, 4), | |
| } | |
| self.status.stats = self.stats() | |
| if blend: | |
| src, dst, i, n = blend | |
| i += 1 | |
| if i >= n: | |
| blend = None | |
| self._set_text(dst) | |
| else: | |
| blend[2] = i | |
| self._set_text(src * (1 - i / n) + dst * (i / n)) | |
| self._push(pixels) | |
| except StreamExhausted as e: | |
| self.status.state, self.status.detail = "idle", str(e) | |
| except Exception as e: # surface, never die silently | |
| self.status.state, self.status.error = "error", f"{type(e).__name__}: {e}" | |
| def stats(self): | |
| st = self.streamer | |
| return { | |
| "frames": self._frames_emitted, | |
| "blocks": self._blocks, | |
| "latent_frames": (st.n_world + st.n_frames) if st else 0, | |
| "latent_frames_max": st.max_frames if st else 0, | |
| "seconds": self._frames_emitted / FPS, | |
| "kv_mb": (st.cache.memory_bytes() / 1e6) if st else 0.0, | |
| **self._timings, | |
| } | |
| def info(self): | |
| return { | |
| "step": self.step, | |
| "weights": self.weights.name, | |
| "worlds": [ | |
| {"idx": i, "prompt": w["prompt"], "generated": w["generated"], | |
| "seconds": 81 / FPS, "gen_seconds": w["seconds"]} | |
| for i, w in sorted(self.worlds.items()) | |
| ], | |
| "prompts": self.bank.catalogue(), | |
| "fps": FPS, | |
| "encoder_loaded": self.encoder.loaded if self.encoder else False, | |
| "worldgen": self.worldgen_available, | |
| "buffer_frames": self.maxframes, | |
| "defaults": DEFAULTS, | |
| } | |