LiveWan / wanstreamer /serve /engine.py
JonathanColetti's picture
LiveWan streaming demo on ZeroGPU
36cdb93 verified
Raw
History Blame Contribute Delete
19.8 kB
"""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
@dataclass
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
@property
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,
}