Spaces:
Running on Zero
Running on Zero
LiveWan streaming demo on ZeroGPU
Browse files- .gitignore +6 -0
- README.md +47 -5
- app.py +308 -0
- requirements.txt +19 -0
- wan21_patches/configs/__init__.py +58 -0
- wan21_patches/modules/attention.py +380 -0
- wanstreamer/__init__.py +14 -0
- wanstreamer/blockcausal.py +423 -0
- wanstreamer/core.py +151 -0
- wanstreamer/data.py +65 -0
- wanstreamer/dmd.py +90 -0
- wanstreamer/fsdp.py +213 -0
- wanstreamer/graphrunner.py +97 -0
- wanstreamer/kvcache.py +105 -0
- wanstreamer/lora.py +95 -0
- wanstreamer/metrics.py +183 -0
- wanstreamer/pipeline.py +305 -0
- wanstreamer/prompts.py +150 -0
- wanstreamer/prompts_ext.py +1051 -0
- wanstreamer/rope.py +77 -0
- wanstreamer/serve/__init__.py +7 -0
- wanstreamer/serve/auth.py +56 -0
- wanstreamer/serve/conditioning.py +94 -0
- wanstreamer/serve/engine.py +482 -0
- wanstreamer/serve/paths.py +22 -0
- wanstreamer/serve/server.py +290 -0
- wanstreamer/serve/streamdecode.py +58 -0
- wanstreamer/serve/web/index.html +822 -0
- wanstreamer/serve/worldgen.py +101 -0
- wanstreamer/stream.py +298 -0
- wanstreamer/text.py +45 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
assets/
|
| 2 |
+
wan21_13b/
|
| 3 |
+
wan21_repo/
|
| 4 |
+
generated_worlds/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
README.md
CHANGED
|
@@ -1,13 +1,55 @@
|
|
| 1 |
---
|
| 2 |
title: LiveWan
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.24.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: LiveWan
|
| 3 |
+
emoji: 🎞️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.24.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
python_version: "3.10"
|
| 10 |
+
short_description: Streaming text-to-video you can steer mid-stream
|
| 11 |
+
startup_duration_timeout: 1h
|
| 12 |
pinned: false
|
| 13 |
+
license: apache-2.0
|
| 14 |
+
models:
|
| 15 |
+
- JonathanColetti/LiveWan
|
| 16 |
+
- Wan-AI/Wan2.1-T2V-1.3B
|
| 17 |
+
tags:
|
| 18 |
+
- text-to-video
|
| 19 |
+
- streaming
|
| 20 |
+
- real-time
|
| 21 |
+
- wan2.1
|
| 22 |
---
|
| 23 |
|
| 24 |
+
# LiveWan
|
| 25 |
+
|
| 26 |
+
Streaming, steerable text-to-video from a 1.3B student distilled out of
|
| 27 |
+
Wan2.1-T2V-14B with SF-DMD. It generates video continuously rather than as a fixed
|
| 28 |
+
clip — 750 ms of 640×368 at a time, extended block by block — and the text
|
| 29 |
+
conditioning can be swapped mid-stream without clearing the K/V cache, so the scene
|
| 30 |
+
continues instead of cutting.
|
| 31 |
+
|
| 32 |
+
- Code: https://github.com/JonathanColetti/LiveWan
|
| 33 |
+
- Weights and data: https://huggingface.co/JonathanColetti/LiveWan
|
| 34 |
+
|
| 35 |
+
## What this Space runs
|
| 36 |
+
|
| 37 |
+
`app.py` drives the project's own serving engine, `wanstreamer.serve.engine.Engine`
|
| 38 |
+
— the same code path `livewan-serve` runs locally. `wanstreamer/` and
|
| 39 |
+
`wan21_patches/` here are copies of the GitHub repo's; the Wan2.1 reference code is
|
| 40 |
+
cloned and patched at startup exactly as `setup.sh` does.
|
| 41 |
+
|
| 42 |
+
Two differences from running it locally, both forced by ZeroGPU:
|
| 43 |
+
|
| 44 |
+
- **Steering is a schedule, not a button.** A GPU worker is forked per request and
|
| 45 |
+
cannot be steered from outside while it runs, so the demo takes the swap up front
|
| 46 |
+
("switch to this prompt at t = N seconds"). The swap itself is the live one:
|
| 47 |
+
`Engine.steer` replaces the cross-attention conditioning and leaves the cache in
|
| 48 |
+
place.
|
| 49 |
+
- **No free text.** Encoding it needs umt5-xxl, 11 GB on top of the 18 GB this
|
| 50 |
+
Space already pulls at startup, so the selectors are the project's 96-prompt bank
|
| 51 |
+
— which is the conditioning every published number refers to. Run the GitHub repo
|
| 52 |
+
locally for free text.
|
| 53 |
+
|
| 54 |
+
`torch.compile` of the VAE decoder is also off, since it cannot run in a ZeroGPU
|
| 55 |
+
worker. That costs roughly 50 ms per block.
|
app.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LiveWan on ZeroGPU: a streaming, steerable text-to-video demo.
|
| 2 |
+
|
| 3 |
+
This Space drives the project's own serving engine (`wanstreamer.serve.engine.Engine`)
|
| 4 |
+
rather than reimplementing the streaming maths. The engine opens a cached world,
|
| 5 |
+
extends it block by block with the distilled 1.3B student, and decodes each block
|
| 6 |
+
through a VAE whose causal-conv cache is kept alive across calls so the blocks join
|
| 7 |
+
without a seam. That is the same code path `livewan-serve` runs locally.
|
| 8 |
+
|
| 9 |
+
What ZeroGPU changes, and why the UI looks the way it does: a GPU worker is forked
|
| 10 |
+
per request and cannot be steered from outside while it runs, so the demo takes the
|
| 11 |
+
steer as a *schedule* -- "start in this world, swap the conditioning to this prompt
|
| 12 |
+
at t = N seconds" -- instead of a live button. The swap itself is exactly the live
|
| 13 |
+
one: `Engine.steer` replaces the cross-attention conditioning and leaves the K/V
|
| 14 |
+
cache in place, so the scene continues rather than cutting.
|
| 15 |
+
|
| 16 |
+
Free text is not available here. Encoding it needs umt5-xxl (11 GB) on top of the
|
| 17 |
+
18 GB this Space already pulls at startup, so the prompt selectors are the project's
|
| 18 |
+
96-prompt bank -- which is the conditioning every published number refers to. Run
|
| 19 |
+
the GitHub repo locally for free text.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
|
| 24 |
+
# Set before torch is imported anywhere: the decoder allocates and frees a
|
| 25 |
+
# pixel-space buffer every block, which is the pattern expandable segments exist for.
|
| 26 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 27 |
+
|
| 28 |
+
import queue
|
| 29 |
+
import shutil
|
| 30 |
+
import subprocess
|
| 31 |
+
import sys
|
| 32 |
+
import tempfile
|
| 33 |
+
import time
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import spaces # must precede torch: it patches torch.cuda.* for module-scope loading
|
| 37 |
+
import torch
|
| 38 |
+
|
| 39 |
+
import cv2
|
| 40 |
+
import gradio as gr
|
| 41 |
+
import imageio.v2 as imageio
|
| 42 |
+
import numpy as np
|
| 43 |
+
from huggingface_hub import snapshot_download
|
| 44 |
+
|
| 45 |
+
APP = Path(__file__).resolve().parent
|
| 46 |
+
ASSETS = APP / "assets"
|
| 47 |
+
BASE_DIR = APP / "wan21_13b"
|
| 48 |
+
WAN_REPO = APP / "wan21_repo"
|
| 49 |
+
WORLDS_DIR = APP / "generated_worlds"
|
| 50 |
+
|
| 51 |
+
LIVEWAN_REPO = "JonathanColetti/LiveWan"
|
| 52 |
+
BASE_REPO = "Wan-AI/Wan2.1-T2V-1.3B"
|
| 53 |
+
GITHUB = "https://github.com/JonathanColetti/LiveWan"
|
| 54 |
+
|
| 55 |
+
FPS = 16
|
| 56 |
+
BLOCK_SECONDS = 0.75 # 3 latent frames -> 12 pixel frames at 16 fps
|
| 57 |
+
NO_STEER = "don't steer, stay on the opening prompt"
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------- bootstrap
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def fetch_weights():
|
| 63 |
+
"""Pull the student, the prompt bank, the four worlds and the Wan2.1 base.
|
| 64 |
+
|
| 65 |
+
The optimiser shards (17.7 GB) are for resuming training and are skipped; so is
|
| 66 |
+
umt5-xxl, which only free text needs.
|
| 67 |
+
"""
|
| 68 |
+
snapshot_download(
|
| 69 |
+
LIVEWAN_REPO, local_dir=str(ASSETS),
|
| 70 |
+
allow_patterns=["checkpoints/t14b_b64/latest.pt", "data/prompts.pt",
|
| 71 |
+
"out/world_p*.pt"])
|
| 72 |
+
# Wan2.1_VAE.pth decodes every block. The base transformer is the scaffold the
|
| 73 |
+
# student's weights are loaded into (see Engine._load_student).
|
| 74 |
+
snapshot_download(
|
| 75 |
+
BASE_REPO, local_dir=str(BASE_DIR),
|
| 76 |
+
allow_patterns=["Wan2.1_VAE.pth", "config.json",
|
| 77 |
+
"diffusion_pytorch_model.safetensors"])
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def install_wan_reference_code():
|
| 81 |
+
"""Clone Wan2.1 and apply the project's two patches, then put it on sys.path.
|
| 82 |
+
|
| 83 |
+
`attention.py` replaces upstream's `assert FLASH_ATTN_2_AVAILABLE` with an SDPA
|
| 84 |
+
fallback that keeps q_lens/k_lens; `configs/__init__.py` adds the 640x368 size
|
| 85 |
+
entries this project streams at. Both are the same files setup.sh copies.
|
| 86 |
+
|
| 87 |
+
`wan/__init__.py` is emptied on purpose. Upstream's eagerly imports the T2V/I2V/
|
| 88 |
+
VACE pipelines, which drag in dashscope, xfuser and `torch.cuda.amp` wrappers this
|
| 89 |
+
demo never calls; only `wan.configs` and `wan.modules` are needed.
|
| 90 |
+
"""
|
| 91 |
+
if not WAN_REPO.exists():
|
| 92 |
+
subprocess.run(["git", "clone", "-q", "--depth", "1",
|
| 93 |
+
"https://github.com/Wan-Video/Wan2.1", str(WAN_REPO)],
|
| 94 |
+
check=True)
|
| 95 |
+
shutil.copy(APP / "wan21_patches/modules/attention.py",
|
| 96 |
+
WAN_REPO / "wan/modules/attention.py")
|
| 97 |
+
shutil.copy(APP / "wan21_patches/configs/__init__.py",
|
| 98 |
+
WAN_REPO / "wan/configs/__init__.py")
|
| 99 |
+
(WAN_REPO / "wan/__init__.py").write_text(
|
| 100 |
+
"# emptied by the LiveWan Space: only wan.configs and wan.modules are used\n")
|
| 101 |
+
if str(WAN_REPO) not in sys.path:
|
| 102 |
+
sys.path.insert(0, str(WAN_REPO))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def ensure_cuda_amp_shim():
|
| 106 |
+
"""`wan.modules.model` imports `torch.cuda.amp`, removed in some torch builds."""
|
| 107 |
+
try:
|
| 108 |
+
import torch.cuda.amp # noqa: F401
|
| 109 |
+
except ImportError:
|
| 110 |
+
import types
|
| 111 |
+
shim = types.ModuleType("torch.cuda.amp")
|
| 112 |
+
shim.autocast = lambda *a, **k: torch.amp.autocast("cuda", *a, **k)
|
| 113 |
+
shim.custom_fwd = torch.amp.custom_fwd
|
| 114 |
+
shim.custom_bwd = torch.amp.custom_bwd
|
| 115 |
+
sys.modules["torch.cuda.amp"] = shim
|
| 116 |
+
torch.cuda.amp = shim
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
print("[boot] downloading weights", flush=True)
|
| 120 |
+
fetch_weights()
|
| 121 |
+
install_wan_reference_code()
|
| 122 |
+
ensure_cuda_amp_shim()
|
| 123 |
+
|
| 124 |
+
from wanstreamer.serve.engine import Engine # noqa: E402 (needs sys.path above)
|
| 125 |
+
|
| 126 |
+
print("[boot] loading the engine", flush=True)
|
| 127 |
+
engine = Engine(
|
| 128 |
+
assets=ASSETS,
|
| 129 |
+
wan_repo=WAN_REPO,
|
| 130 |
+
base_dir=BASE_DIR,
|
| 131 |
+
weights=ASSETS / "checkpoints/t14b_b64/latest.pt",
|
| 132 |
+
device="cuda",
|
| 133 |
+
worlds_dir=WORLDS_DIR,
|
| 134 |
+
allow_worldgen=False, # the 5.7 GB base is the load scaffold, not a second model
|
| 135 |
+
compile_vae=False, # torch.compile cannot run in a ZeroGPU worker
|
| 136 |
+
)
|
| 137 |
+
engine.load(progress=lambda m: print(f"[boot] {m}", flush=True))
|
| 138 |
+
|
| 139 |
+
INFO = engine.info()
|
| 140 |
+
PROMPTS = {f"{p['idx']:>2} · {p['text']}": p["idx"] for p in INFO["prompts"]}
|
| 141 |
+
WORLDS = {f"world {w['idx']} · {w['prompt'][:70]}": w["idx"] for w in INFO["worlds"]}
|
| 142 |
+
WORLD_LABELS = list(WORLDS)
|
| 143 |
+
PROMPT_LABELS = list(PROMPTS)
|
| 144 |
+
print(f"[boot] ready: step {INFO['step']}, {len(PROMPTS)} prompts, "
|
| 145 |
+
f"{len(WORLDS)} worlds", flush=True)
|
| 146 |
+
|
| 147 |
+
# ------------------------------------------------------------------- render
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def hud(stats, wall):
|
| 151 |
+
"""The numbers the browser demo puts along the bottom of the stream."""
|
| 152 |
+
total = stats.get("total_s") or 0.0
|
| 153 |
+
rt = (BLOCK_SECONDS / total) if total else 0.0
|
| 154 |
+
return (
|
| 155 |
+
f"**{stats['seconds']:.2f} s** of video · {stats['blocks']} blocks · "
|
| 156 |
+
f"latent frames **{stats['latent_frames']}/{stats['latent_frames_max']}**\n\n"
|
| 157 |
+
f"block **{total * 1000:.0f} ms** "
|
| 158 |
+
f"(generate {stats.get('gen_s', 0) * 1000:.0f} ms, "
|
| 159 |
+
f"decode {stats.get('decode_s', 0) * 1000:.0f} ms) · "
|
| 160 |
+
f"**{rt:.2f}x real time** · K/V cache **{stats['kv_mb']:.0f} MB** · "
|
| 161 |
+
f"{wall:.1f} s on the GPU"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def write_mp4(frames):
|
| 166 |
+
path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
|
| 167 |
+
with imageio.get_writer(path, fps=FPS, codec="libx264", quality=8,
|
| 168 |
+
macro_block_size=1,
|
| 169 |
+
ffmpeg_params=["-pix_fmt", "yuv420p"]) as w:
|
| 170 |
+
for f in frames:
|
| 171 |
+
w.append_data(f)
|
| 172 |
+
return path
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _duration(world, steer_to, steer_at, seconds, seed, *args, **kwargs):
|
| 176 |
+
# Worst case is the whole clip generated at the slowest observed rate, plus the
|
| 177 |
+
# world decode at the start. Declared tight on purpose: ZeroGPU compares the
|
| 178 |
+
# request against the visitor's remaining quota, not the actual runtime.
|
| 179 |
+
return int(min(200, 45 + float(seconds) * 1.2))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@spaces.GPU(duration=_duration)
|
| 183 |
+
def run(world: str, steer_to: str, steer_at: float, seconds: float, seed: int):
|
| 184 |
+
"""Stream video from a cached world, optionally swapping the prompt mid-stream.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
world: which of the four shipped worlds to open the stream on.
|
| 188 |
+
steer_to: a prompt from the 96-prompt bank to swap to, or the no-steer option.
|
| 189 |
+
steer_at: seconds into the clip at which to swap the conditioning.
|
| 190 |
+
seconds: how much video to generate, at 16 fps.
|
| 191 |
+
seed: RNG seed for the block sampler.
|
| 192 |
+
|
| 193 |
+
Yields:
|
| 194 |
+
(preview frame, HUD line, finished mp4) — the mp4 only on the last yield.
|
| 195 |
+
"""
|
| 196 |
+
total_frames = int(float(seconds) * FPS)
|
| 197 |
+
steer_frame = int(float(steer_at) * FPS)
|
| 198 |
+
steer_idx = PROMPTS.get(steer_to) if steer_to and steer_to != NO_STEER else None
|
| 199 |
+
|
| 200 |
+
t0 = time.perf_counter()
|
| 201 |
+
engine.start(world=WORLDS[world], seed=int(seed))
|
| 202 |
+
frames, pending_steer, last_push = [], steer_idx is not None, 0.0
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
while len(frames) < total_frames:
|
| 206 |
+
if engine.status.state == "error":
|
| 207 |
+
raise gr.Error(f"engine error: {engine.status.error}")
|
| 208 |
+
try:
|
| 209 |
+
jpg = engine.frames.get(timeout=60)
|
| 210 |
+
except queue.Empty:
|
| 211 |
+
# The worker sets state to idle and puts the reason in `detail` when
|
| 212 |
+
# a stream ends on its own (the 1024-latent-frame RoPE ceiling).
|
| 213 |
+
if engine.status.state != "streaming":
|
| 214 |
+
break
|
| 215 |
+
raise gr.Error("the stream stalled waiting for a block")
|
| 216 |
+
|
| 217 |
+
frames.append(cv2.imdecode(np.frombuffer(jpg, np.uint8),
|
| 218 |
+
cv2.IMREAD_COLOR)[:, :, ::-1])
|
| 219 |
+
|
| 220 |
+
if pending_steer and len(frames) >= steer_frame:
|
| 221 |
+
engine.steer(idx=steer_idx)
|
| 222 |
+
pending_steer = False
|
| 223 |
+
|
| 224 |
+
now = time.perf_counter()
|
| 225 |
+
if now - last_push > 0.12:
|
| 226 |
+
last_push = now
|
| 227 |
+
yield frames[-1], hud(engine.stats(), now - t0), None
|
| 228 |
+
finally:
|
| 229 |
+
engine.stop()
|
| 230 |
+
|
| 231 |
+
if not frames:
|
| 232 |
+
raise gr.Error("no frames were produced")
|
| 233 |
+
note = engine.status.detail
|
| 234 |
+
line = hud(engine.stats(), time.perf_counter() - t0)
|
| 235 |
+
if note:
|
| 236 |
+
line += f"\n\n_{note}_"
|
| 237 |
+
yield frames[-1], line, write_mp4(frames)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ----------------------------------------------------------------------- ui
|
| 241 |
+
|
| 242 |
+
CSS = """
|
| 243 |
+
#col-container { max-width: 1180px; margin: 0 auto; }
|
| 244 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
| 245 |
+
"""
|
| 246 |
+
|
| 247 |
+
INTRO = f"""# LiveWan
|
| 248 |
+
|
| 249 |
+
**Streaming text-to-video you can steer while it runs.** A 1.3B student distilled
|
| 250 |
+
from Wan2.1-T2V-14B that generates video continuously instead of as a fixed clip:
|
| 251 |
+
750 ms of 640x368 at a time, extended block by block.
|
| 252 |
+
|
| 253 |
+
Pick a world to open on, and optionally a prompt to swap to partway through. The
|
| 254 |
+
swap keeps the K/V cache, so the scene *continues* rather than cutting. Checkpoint
|
| 255 |
+
step {INFO['step']}.
|
| 256 |
+
|
| 257 |
+
[Code]({GITHUB}) · [Weights](https://huggingface.co/{LIVEWAN_REPO})
|
| 258 |
+
"""
|
| 259 |
+
|
| 260 |
+
NOTES = f"""
|
| 261 |
+
- The live preview runs as fast as the GPU produces frames, which is faster than
|
| 262 |
+
real time. The **mp4 below it is the honest 16 fps playback** — watch that one.
|
| 263 |
+
- Prompts come from the project's 96-prompt bank, the conditioning every published
|
| 264 |
+
number refers to. Free text needs umt5-xxl (11 GB) and is not loaded here; the
|
| 265 |
+
[local demo]({GITHUB}) has it.
|
| 266 |
+
- One stream at a time: the engine holds a single K/V cache, so requests queue.
|
| 267 |
+
- A stream cannot exceed 1024 latent frames (~4.3 min) — that is where `WanModel`'s
|
| 268 |
+
RoPE tables end. It stops itself and says so.
|
| 269 |
+
"""
|
| 270 |
+
|
| 271 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="LiveWan") as demo:
|
| 272 |
+
with gr.Column(elem_id="col-container"):
|
| 273 |
+
gr.Markdown(INTRO)
|
| 274 |
+
|
| 275 |
+
with gr.Row():
|
| 276 |
+
world = gr.Dropdown(WORLD_LABELS, value=WORLD_LABELS[2],
|
| 277 |
+
label="Open on world", scale=1)
|
| 278 |
+
steer_to = gr.Dropdown([NO_STEER] + PROMPT_LABELS, value=NO_STEER,
|
| 279 |
+
label="Steer to", scale=1)
|
| 280 |
+
with gr.Row():
|
| 281 |
+
steer_at = gr.Slider(1, 25, value=6, step=0.5,
|
| 282 |
+
label="Steer at (seconds in)", scale=1)
|
| 283 |
+
seconds = gr.Slider(5, 30, value=15, step=1,
|
| 284 |
+
label="Generate (seconds of video)", scale=1)
|
| 285 |
+
seed = gr.Number(value=0, precision=0, label="Seed", scale=0)
|
| 286 |
+
run_btn = gr.Button("Stream", variant="primary")
|
| 287 |
+
|
| 288 |
+
preview = gr.Image(label="Live preview (faster than real time)",
|
| 289 |
+
height=368, show_download_button=False)
|
| 290 |
+
stats = gr.Markdown()
|
| 291 |
+
video = gr.Video(label="The clip, at 16 fps", autoplay=True)
|
| 292 |
+
|
| 293 |
+
gr.Examples(
|
| 294 |
+
examples=[
|
| 295 |
+
[WORLD_LABELS[2], NO_STEER, 6, 20, 0],
|
| 296 |
+
[WORLD_LABELS[2], PROMPT_LABELS[60], 6, 18, 0],
|
| 297 |
+
[WORLD_LABELS[0], PROMPT_LABELS[3], 8, 18, 0],
|
| 298 |
+
[WORLD_LABELS[1], PROMPT_LABELS[45], 7, 18, 0],
|
| 299 |
+
],
|
| 300 |
+
inputs=[world, steer_to, steer_at, seconds, seed],
|
| 301 |
+
label="Try one",
|
| 302 |
+
)
|
| 303 |
+
gr.Markdown(NOTES)
|
| 304 |
+
|
| 305 |
+
run_btn.click(run, inputs=[world, steer_to, steer_at, seconds, seed],
|
| 306 |
+
outputs=[preview, stats, video], concurrency_limit=1)
|
| 307 |
+
|
| 308 |
+
demo.queue(max_size=12).launch(mcp_server=True)
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# torch, gradio, spaces and huggingface_hub come with the ZeroGPU Gradio image.
|
| 2 |
+
torchvision
|
| 3 |
+
numpy
|
| 4 |
+
einops
|
| 5 |
+
safetensors
|
| 6 |
+
transformers
|
| 7 |
+
tokenizers
|
| 8 |
+
sentencepiece
|
| 9 |
+
accelerate
|
| 10 |
+
diffusers
|
| 11 |
+
# the Wan2.1 reference package's own needs
|
| 12 |
+
easydict
|
| 13 |
+
ftfy
|
| 14 |
+
regex
|
| 15 |
+
tqdm
|
| 16 |
+
# this Space: jpeg frames out of the engine, mp4 in
|
| 17 |
+
opencv-python-headless
|
| 18 |
+
imageio
|
| 19 |
+
imageio-ffmpeg
|
wan21_patches/configs/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
|
| 2 |
+
import copy
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
os.environ['TOKENIZERS_PARALLELISM'] = 'false'
|
| 6 |
+
|
| 7 |
+
from .wan_i2v_14B import i2v_14B
|
| 8 |
+
from .wan_t2v_1_3B import t2v_1_3B
|
| 9 |
+
from .wan_t2v_14B import t2v_14B
|
| 10 |
+
|
| 11 |
+
# the config of t2i_14B is the same as t2v_14B
|
| 12 |
+
t2i_14B = copy.deepcopy(t2v_14B)
|
| 13 |
+
t2i_14B.__name__ = 'Config: Wan T2I 14B'
|
| 14 |
+
|
| 15 |
+
# the config of flf2v_14B is the same as i2v_14B
|
| 16 |
+
flf2v_14B = copy.deepcopy(i2v_14B)
|
| 17 |
+
flf2v_14B.__name__ = 'Config: Wan FLF2V 14B'
|
| 18 |
+
flf2v_14B.sample_neg_prompt = "镜头切换," + flf2v_14B.sample_neg_prompt
|
| 19 |
+
|
| 20 |
+
WAN_CONFIGS = {
|
| 21 |
+
't2v-14B': t2v_14B,
|
| 22 |
+
't2v-1.3B': t2v_1_3B,
|
| 23 |
+
'i2v-14B': i2v_14B,
|
| 24 |
+
't2i-14B': t2i_14B,
|
| 25 |
+
'flf2v-14B': flf2v_14B,
|
| 26 |
+
'vace-1.3B': t2v_1_3B,
|
| 27 |
+
'vace-14B': t2v_14B,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
SIZE_CONFIGS = {
|
| 31 |
+
'720*1280': (720, 1280),
|
| 32 |
+
'1280*720': (1280, 720),
|
| 33 |
+
'480*832': (480, 832),
|
| 34 |
+
'832*480': (832, 480),
|
| 35 |
+
'1024*1024': (1024, 1024),
|
| 36 |
+
# Wan-Streamer v0.2 target resolution (landscape). Legal for t2v-1.3B:
|
| 37 |
+
# vae_stride=(4,8,8), patch_size=(1,2,2) -> 640/8/2 = 40, 368/8/2 = 23,
|
| 38 |
+
# giving a 23x40 = 920-token latent frame.
|
| 39 |
+
'640*368': (640, 368),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
MAX_AREA_CONFIGS = {
|
| 43 |
+
'720*1280': 720 * 1280,
|
| 44 |
+
'1280*720': 1280 * 720,
|
| 45 |
+
'480*832': 480 * 832,
|
| 46 |
+
'832*480': 832 * 480,
|
| 47 |
+
'640*368': 640 * 368,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
SUPPORTED_SIZES = {
|
| 51 |
+
't2v-14B': ('720*1280', '1280*720', '480*832', '832*480'),
|
| 52 |
+
't2v-1.3B': ('480*832', '832*480', '640*368'),
|
| 53 |
+
'i2v-14B': ('720*1280', '1280*720', '480*832', '832*480'),
|
| 54 |
+
'flf2v-14B': ('720*1280', '1280*720', '480*832', '832*480'),
|
| 55 |
+
't2i-14B': tuple(SIZE_CONFIGS.keys()),
|
| 56 |
+
'vace-1.3B': ('480*832', '832*480'),
|
| 57 |
+
'vace-14B': ('720*1280', '1280*720', '480*832', '832*480')
|
| 58 |
+
}
|
wan21_patches/modules/attention.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
|
| 2 |
+
#
|
| 3 |
+
# MODIFIED for Blackwell (sm_100) / no-flash-attn operation.
|
| 4 |
+
#
|
| 5 |
+
# Why: every attention call site in this repo (model.py:149/179/220/222,
|
| 6 |
+
# clip.py:85/197, streaming_blocks.py) calls `flash_attention`, NOT `attention`.
|
| 7 |
+
# Upstream `flash_attention` ends in `assert FLASH_ATTN_2_AVAILABLE`, so with no
|
| 8 |
+
# flash_attn wheel (none exists for sm_100) every forward pass raised a bare
|
| 9 |
+
# AssertionError. The SDPA fallback that upstream put in `attention()` had zero
|
| 10 |
+
# callers and was dead code.
|
| 11 |
+
#
|
| 12 |
+
# Fix: `flash_attention` now dispatches to a correct SDPA implementation when no
|
| 13 |
+
# flash-attn is installed. Unlike upstream's `attention()`, this fallback does NOT
|
| 14 |
+
# discard q_lens/k_lens -- it materialises an explicit boolean mask whenever the
|
| 15 |
+
# lengths imply real padding. Silently attending over padding was the specific
|
| 16 |
+
# "runs fine, quietly wrong" failure mode we had to rule out.
|
| 17 |
+
import os
|
| 18 |
+
import warnings
|
| 19 |
+
from contextlib import nullcontext
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
import flash_attn_interface
|
| 26 |
+
FLASH_ATTN_3_AVAILABLE = True
|
| 27 |
+
except ModuleNotFoundError:
|
| 28 |
+
FLASH_ATTN_3_AVAILABLE = False
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
import flash_attn
|
| 32 |
+
FLASH_ATTN_2_AVAILABLE = True
|
| 33 |
+
except ModuleNotFoundError:
|
| 34 |
+
FLASH_ATTN_2_AVAILABLE = False
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
'flash_attention',
|
| 38 |
+
'attention',
|
| 39 |
+
'sdpa_attention',
|
| 40 |
+
'sdpa_backend_ctx',
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
# --- SDPA backend selection -------------------------------------------------
|
| 44 |
+
# cuDNN attention is not an alternative to SDPA; it is one of SDPA's backends.
|
| 45 |
+
# Measured on this machine (B200 / sm_100, torch 2.13.0+cu130, cuDNN 9.2.0),
|
| 46 |
+
# bf16, 12 heads x 128 dim, time per call:
|
| 47 |
+
#
|
| 48 |
+
# shape (Lq x Lk) FLASH MEM_EFF CUDNN MATH
|
| 49 |
+
# 7800 x 7800 (480x832) 0.967 2.258 0.281 OOM-ish (1.4 GiB)
|
| 50 |
+
# 4600 x 4600 (640x368) 0.353 0.820 0.117 4.955
|
| 51 |
+
# 7800 x 512 (cross) 0.074 0.161 0.030 0.922
|
| 52 |
+
# 1560 x 6240 (stream) 0.316 0.579 0.079 2.420
|
| 53 |
+
# 1560 x 6240 + bool mask n/a 0.747 0.212 2.850
|
| 54 |
+
#
|
| 55 |
+
# So cuDNN is 3-4x faster than the flash backend here, and it is the only fused
|
| 56 |
+
# backend that accepts an explicit attn_mask (flash rejects non-null masks, which
|
| 57 |
+
# would otherwise drop the padded path to MATH at ~13x the cost).
|
| 58 |
+
#
|
| 59 |
+
# torch's default dispatch already prefers cuDNN at these shapes, but that is an
|
| 60 |
+
# implicit heuristic that varies by version and shape. Pin the order explicitly
|
| 61 |
+
# so the fast path is guaranteed rather than incidental. All backends stay
|
| 62 |
+
# enabled, so an unsupported shape degrades instead of raising.
|
| 63 |
+
#
|
| 64 |
+
# NOTE: because cuDNN was already the default, this pinning is a robustness
|
| 65 |
+
# measure, not a speedup -- do not book it as one.
|
| 66 |
+
_BACKEND_ORDER = ['CUDNN_ATTENTION', 'FLASH_ATTENTION', 'EFFICIENT_ATTENTION', 'MATH']
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _build_backend_ctx():
|
| 70 |
+
"""Return a callable giving a context manager that pins SDPA backend order."""
|
| 71 |
+
try:
|
| 72 |
+
from torch.nn.attention import SDPBackend, sdpa_kernel
|
| 73 |
+
except ImportError:
|
| 74 |
+
return lambda: nullcontext()
|
| 75 |
+
|
| 76 |
+
# WAN_SDPA_BACKEND forces a single backend, for benchmarking/debugging only.
|
| 77 |
+
# It disables all fallbacks, so e.g. WAN_SDPA_BACKEND=FLASH_ATTENTION will
|
| 78 |
+
# raise "No available kernel" on any masked call. That is intended.
|
| 79 |
+
override = os.environ.get('WAN_SDPA_BACKEND', '').strip().upper()
|
| 80 |
+
order = [override] if override else _BACKEND_ORDER
|
| 81 |
+
backends = [getattr(SDPBackend, n) for n in order if hasattr(SDPBackend, n)]
|
| 82 |
+
if not backends:
|
| 83 |
+
return lambda: nullcontext()
|
| 84 |
+
|
| 85 |
+
# set_priority=True keeps every listed backend enabled but fixes the order.
|
| 86 |
+
try:
|
| 87 |
+
with sdpa_kernel(backends, set_priority=True):
|
| 88 |
+
pass
|
| 89 |
+
except TypeError:
|
| 90 |
+
return lambda: sdpa_kernel(backends)
|
| 91 |
+
return lambda: sdpa_kernel(backends, set_priority=True)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
sdpa_backend_ctx = _build_backend_ctx()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _needs_mask(lens, length):
|
| 98 |
+
"""True if `lens` describes anything shorter than the padded `length`."""
|
| 99 |
+
if lens is None:
|
| 100 |
+
return False
|
| 101 |
+
return bool(torch.as_tensor(lens).min().item() < length)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def sdpa_attention(
|
| 105 |
+
q,
|
| 106 |
+
k,
|
| 107 |
+
v,
|
| 108 |
+
q_lens=None,
|
| 109 |
+
k_lens=None,
|
| 110 |
+
dropout_p=0.,
|
| 111 |
+
softmax_scale=None,
|
| 112 |
+
q_scale=None,
|
| 113 |
+
causal=False,
|
| 114 |
+
window_size=(-1, -1),
|
| 115 |
+
dtype=torch.bfloat16,
|
| 116 |
+
):
|
| 117 |
+
"""Correct SDPA replacement for flash_attn_varlen_func.
|
| 118 |
+
|
| 119 |
+
Shapes match the flash-attn convention:
|
| 120 |
+
q: [B, Lq, Nq, C1] k: [B, Lk, Nk, C1] v: [B, Lk, Nk, C2]
|
| 121 |
+
Returns [B, Lq, Nq, C2] in q's original dtype.
|
| 122 |
+
|
| 123 |
+
Semantics deliberately mirror flash-attn's varlen kernel:
|
| 124 |
+
|
| 125 |
+
* `q_lens` / `k_lens` are HONOURED. Keys at index >= k_lens[b] are masked out;
|
| 126 |
+
query rows at index >= q_lens[b] are zeroed on output. Upstream's
|
| 127 |
+
`attention()` set attn_mask=None here and only warned, which computes
|
| 128 |
+
attention over padding and is silently wrong.
|
| 129 |
+
* `causal=True` uses BOTTOM-RIGHT alignment when Lq != Lk, matching
|
| 130 |
+
flash-attn (query i sees keys 0 .. i + Lk - Lq). Note torch's
|
| 131 |
+
`is_causal=True` is TOP-LEFT aligned instead, so the two disagree for
|
| 132 |
+
Lq != Lk -- that discrepancy is exactly the streaming-KV-cache bug this
|
| 133 |
+
project had to fix. For block-causal streaming where the K/V cache holds
|
| 134 |
+
only past+current frames, the correct call is `causal=False`: causality is
|
| 135 |
+
already enforced by cache contents, and a mask would wrongly serialise
|
| 136 |
+
tokens *within* a frame.
|
| 137 |
+
"""
|
| 138 |
+
assert tuple(window_size) == (-1, -1), (
|
| 139 |
+
f'sliding-window attention (window_size={window_size}) has no SDPA '
|
| 140 |
+
'fallback; it would be silently ignored.')
|
| 141 |
+
|
| 142 |
+
b, lq, nq, _ = q.shape
|
| 143 |
+
lk, nk = k.shape[1], k.shape[2]
|
| 144 |
+
out_dtype = q.dtype
|
| 145 |
+
|
| 146 |
+
q = q.to(dtype)
|
| 147 |
+
k = k.to(dtype)
|
| 148 |
+
v = v.to(dtype)
|
| 149 |
+
|
| 150 |
+
if q_scale is not None:
|
| 151 |
+
q = q * q_scale
|
| 152 |
+
|
| 153 |
+
# [B, L, N, C] -> [B, N, L, C]
|
| 154 |
+
q = q.transpose(1, 2)
|
| 155 |
+
k = k.transpose(1, 2)
|
| 156 |
+
v = v.transpose(1, 2)
|
| 157 |
+
|
| 158 |
+
# grouped-query attention: replicate k/v heads to match q heads
|
| 159 |
+
if nq != nk:
|
| 160 |
+
assert nq % nk == 0, f'Nq ({nq}) must be divisible by Nk ({nk})'
|
| 161 |
+
rep = nq // nk
|
| 162 |
+
k = k.repeat_interleave(rep, dim=1)
|
| 163 |
+
v = v.repeat_interleave(rep, dim=1)
|
| 164 |
+
|
| 165 |
+
mask_k = _needs_mask(k_lens, lk)
|
| 166 |
+
mask_q = _needs_mask(q_lens, lq)
|
| 167 |
+
|
| 168 |
+
if not mask_k and (not causal or lq == lk):
|
| 169 |
+
# Fast path: no padding, and either no mask or a square causal mask
|
| 170 |
+
# (square => top-left and bottom-right alignment coincide).
|
| 171 |
+
with sdpa_backend_ctx():
|
| 172 |
+
out = F.scaled_dot_product_attention(
|
| 173 |
+
q, k, v, attn_mask=None, is_causal=causal,
|
| 174 |
+
dropout_p=dropout_p, scale=softmax_scale)
|
| 175 |
+
elif (mask_k and not causal
|
| 176 |
+
and int(torch.as_tensor(k_lens).min()) > 0):
|
| 177 |
+
# Key-padding only. The mask does not depend on the query index, so a
|
| 178 |
+
# [B, 1, 1, Lk] mask is exactly equivalent to the [B, 1, Lq, Lk] one
|
| 179 |
+
# built below and SDPA broadcasts it. Worth a special case: the block-
|
| 180 |
+
# causal trainer batches blocks with differing prefix lengths, where the
|
| 181 |
+
# dense form would be a 244 MiB bool tensor per attention call.
|
| 182 |
+
kl = torch.as_tensor(k_lens, device=q.device).reshape(b, 1, 1, 1)
|
| 183 |
+
keep = torch.arange(lk, device=q.device).reshape(1, 1, 1, lk) < kl
|
| 184 |
+
with sdpa_backend_ctx():
|
| 185 |
+
out = F.scaled_dot_product_attention(
|
| 186 |
+
q, k, v, attn_mask=keep, is_causal=False,
|
| 187 |
+
dropout_p=dropout_p, scale=softmax_scale)
|
| 188 |
+
else:
|
| 189 |
+
# Build an explicit boolean keep-mask [B, 1, Lq, Lk].
|
| 190 |
+
keep = torch.ones(b, 1, lq, lk, dtype=torch.bool, device=q.device)
|
| 191 |
+
|
| 192 |
+
if mask_k:
|
| 193 |
+
kl = torch.as_tensor(k_lens, device=q.device).reshape(b, 1, 1, 1)
|
| 194 |
+
key_idx = torch.arange(lk, device=q.device).reshape(1, 1, 1, lk)
|
| 195 |
+
keep &= key_idx < kl
|
| 196 |
+
|
| 197 |
+
if causal:
|
| 198 |
+
# bottom-right aligned: key j visible to query i iff j <= i + (lk - lq)
|
| 199 |
+
qi = torch.arange(lq, device=q.device).reshape(1, 1, lq, 1)
|
| 200 |
+
kj = torch.arange(lk, device=q.device).reshape(1, 1, 1, lk)
|
| 201 |
+
keep &= kj <= qi + (lk - lq)
|
| 202 |
+
|
| 203 |
+
# A row with nothing visible would give NaN from softmax. Let such rows
|
| 204 |
+
# attend to key 0, then zero the result below.
|
| 205 |
+
dead = ~keep.any(dim=-1, keepdim=True)
|
| 206 |
+
if dead.any():
|
| 207 |
+
keep = keep | (dead & (torch.arange(lk, device=q.device) == 0).reshape(1, 1, 1, lk))
|
| 208 |
+
|
| 209 |
+
with sdpa_backend_ctx():
|
| 210 |
+
out = F.scaled_dot_product_attention(
|
| 211 |
+
q, k, v, attn_mask=keep, is_causal=False,
|
| 212 |
+
dropout_p=dropout_p, scale=softmax_scale)
|
| 213 |
+
if dead.any():
|
| 214 |
+
out = out.masked_fill(dead, 0.0)
|
| 215 |
+
|
| 216 |
+
out = out.transpose(1, 2).contiguous() # -> [B, Lq, Nq, C2]
|
| 217 |
+
|
| 218 |
+
if mask_q:
|
| 219 |
+
ql = torch.as_tensor(q_lens, device=out.device).reshape(b, 1, 1, 1)
|
| 220 |
+
qi = torch.arange(lq, device=out.device).reshape(1, lq, 1, 1)
|
| 221 |
+
out = out * (qi < ql)
|
| 222 |
+
|
| 223 |
+
return out.to(out_dtype)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def flash_attention(
|
| 227 |
+
q,
|
| 228 |
+
k,
|
| 229 |
+
v,
|
| 230 |
+
q_lens=None,
|
| 231 |
+
k_lens=None,
|
| 232 |
+
dropout_p=0.,
|
| 233 |
+
softmax_scale=None,
|
| 234 |
+
q_scale=None,
|
| 235 |
+
causal=False,
|
| 236 |
+
window_size=(-1, -1),
|
| 237 |
+
deterministic=False,
|
| 238 |
+
dtype=torch.bfloat16,
|
| 239 |
+
version=None,
|
| 240 |
+
):
|
| 241 |
+
"""
|
| 242 |
+
q: [B, Lq, Nq, C1].
|
| 243 |
+
k: [B, Lk, Nk, C1].
|
| 244 |
+
v: [B, Lk, Nk, C2]. Nq must be divisible by Nk.
|
| 245 |
+
q_lens: [B].
|
| 246 |
+
k_lens: [B].
|
| 247 |
+
dropout_p: float. Dropout probability.
|
| 248 |
+
softmax_scale: float. The scaling of QK^T before applying softmax.
|
| 249 |
+
causal: bool. Whether to apply causal attention mask.
|
| 250 |
+
window_size: (left right). If not (-1, -1), apply sliding window local attention.
|
| 251 |
+
deterministic: bool. If True, slightly slower and uses more memory.
|
| 252 |
+
dtype: torch.dtype. Apply when dtype of q/k/v is not float16/bfloat16.
|
| 253 |
+
"""
|
| 254 |
+
half_dtypes = (torch.float16, torch.bfloat16)
|
| 255 |
+
assert dtype in half_dtypes
|
| 256 |
+
assert q.device.type == 'cuda' and q.size(-1) <= 256
|
| 257 |
+
|
| 258 |
+
if not (FLASH_ATTN_2_AVAILABLE or FLASH_ATTN_3_AVAILABLE):
|
| 259 |
+
# No flash-attn (e.g. Blackwell sm_100): use the correct SDPA path.
|
| 260 |
+
return sdpa_attention(
|
| 261 |
+
q=q,
|
| 262 |
+
k=k,
|
| 263 |
+
v=v,
|
| 264 |
+
q_lens=q_lens,
|
| 265 |
+
k_lens=k_lens,
|
| 266 |
+
dropout_p=dropout_p,
|
| 267 |
+
softmax_scale=softmax_scale,
|
| 268 |
+
q_scale=q_scale,
|
| 269 |
+
causal=causal,
|
| 270 |
+
window_size=window_size,
|
| 271 |
+
dtype=dtype,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# params
|
| 275 |
+
b, lq, lk, out_dtype = q.size(0), q.size(1), k.size(1), q.dtype
|
| 276 |
+
|
| 277 |
+
def half(x):
|
| 278 |
+
return x if x.dtype in half_dtypes else x.to(dtype)
|
| 279 |
+
|
| 280 |
+
# preprocess query
|
| 281 |
+
if q_lens is None:
|
| 282 |
+
q = half(q.flatten(0, 1))
|
| 283 |
+
q_lens = torch.tensor(
|
| 284 |
+
[lq] * b, dtype=torch.int32).to(
|
| 285 |
+
device=q.device, non_blocking=True)
|
| 286 |
+
else:
|
| 287 |
+
q = half(torch.cat([u[:v] for u, v in zip(q, q_lens)]))
|
| 288 |
+
|
| 289 |
+
# preprocess key, value
|
| 290 |
+
if k_lens is None:
|
| 291 |
+
k = half(k.flatten(0, 1))
|
| 292 |
+
v = half(v.flatten(0, 1))
|
| 293 |
+
k_lens = torch.tensor(
|
| 294 |
+
[lk] * b, dtype=torch.int32).to(
|
| 295 |
+
device=k.device, non_blocking=True)
|
| 296 |
+
else:
|
| 297 |
+
k = half(torch.cat([u[:v] for u, v in zip(k, k_lens)]))
|
| 298 |
+
v = half(torch.cat([u[:v] for u, v in zip(v, k_lens)]))
|
| 299 |
+
|
| 300 |
+
q = q.to(v.dtype)
|
| 301 |
+
k = k.to(v.dtype)
|
| 302 |
+
|
| 303 |
+
if q_scale is not None:
|
| 304 |
+
q = q * q_scale
|
| 305 |
+
|
| 306 |
+
if version is not None and version == 3 and not FLASH_ATTN_3_AVAILABLE:
|
| 307 |
+
warnings.warn(
|
| 308 |
+
'Flash attention 3 is not available, use flash attention 2 instead.'
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# apply attention
|
| 312 |
+
if (version is None or version == 3) and FLASH_ATTN_3_AVAILABLE:
|
| 313 |
+
# Note: dropout_p, window_size are not supported in FA3 now.
|
| 314 |
+
x = flash_attn_interface.flash_attn_varlen_func(
|
| 315 |
+
q=q,
|
| 316 |
+
k=k,
|
| 317 |
+
v=v,
|
| 318 |
+
cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum(
|
| 319 |
+
0, dtype=torch.int32).to(q.device, non_blocking=True),
|
| 320 |
+
cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum(
|
| 321 |
+
0, dtype=torch.int32).to(q.device, non_blocking=True),
|
| 322 |
+
seqused_q=None,
|
| 323 |
+
seqused_k=None,
|
| 324 |
+
max_seqlen_q=lq,
|
| 325 |
+
max_seqlen_k=lk,
|
| 326 |
+
softmax_scale=softmax_scale,
|
| 327 |
+
causal=causal,
|
| 328 |
+
deterministic=deterministic)[0].unflatten(0, (b, lq))
|
| 329 |
+
else:
|
| 330 |
+
assert FLASH_ATTN_2_AVAILABLE
|
| 331 |
+
x = flash_attn.flash_attn_varlen_func(
|
| 332 |
+
q=q,
|
| 333 |
+
k=k,
|
| 334 |
+
v=v,
|
| 335 |
+
cu_seqlens_q=torch.cat([q_lens.new_zeros([1]), q_lens]).cumsum(
|
| 336 |
+
0, dtype=torch.int32).to(q.device, non_blocking=True),
|
| 337 |
+
cu_seqlens_k=torch.cat([k_lens.new_zeros([1]), k_lens]).cumsum(
|
| 338 |
+
0, dtype=torch.int32).to(q.device, non_blocking=True),
|
| 339 |
+
max_seqlen_q=lq,
|
| 340 |
+
max_seqlen_k=lk,
|
| 341 |
+
dropout_p=dropout_p,
|
| 342 |
+
softmax_scale=softmax_scale,
|
| 343 |
+
causal=causal,
|
| 344 |
+
window_size=window_size,
|
| 345 |
+
deterministic=deterministic).unflatten(0, (b, lq))
|
| 346 |
+
|
| 347 |
+
# output
|
| 348 |
+
return x.type(out_dtype)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def attention(
|
| 352 |
+
q,
|
| 353 |
+
k,
|
| 354 |
+
v,
|
| 355 |
+
q_lens=None,
|
| 356 |
+
k_lens=None,
|
| 357 |
+
dropout_p=0.,
|
| 358 |
+
softmax_scale=None,
|
| 359 |
+
q_scale=None,
|
| 360 |
+
causal=False,
|
| 361 |
+
window_size=(-1, -1),
|
| 362 |
+
deterministic=False,
|
| 363 |
+
dtype=torch.bfloat16,
|
| 364 |
+
fa_version=None,
|
| 365 |
+
):
|
| 366 |
+
return flash_attention(
|
| 367 |
+
q=q,
|
| 368 |
+
k=k,
|
| 369 |
+
v=v,
|
| 370 |
+
q_lens=q_lens,
|
| 371 |
+
k_lens=k_lens,
|
| 372 |
+
dropout_p=dropout_p,
|
| 373 |
+
softmax_scale=softmax_scale,
|
| 374 |
+
q_scale=q_scale,
|
| 375 |
+
causal=causal,
|
| 376 |
+
window_size=window_size,
|
| 377 |
+
deterministic=deterministic,
|
| 378 |
+
dtype=dtype,
|
| 379 |
+
version=fa_version,
|
| 380 |
+
)
|
wanstreamer/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corrected block-causal streaming implementation for Wan2.1.
|
| 2 |
+
|
| 3 |
+
See PROGRESS.md for the measurements behind each design decision.
|
| 4 |
+
"""
|
| 5 |
+
from .rope import RopeTable, apply_rope
|
| 6 |
+
from .kvcache import StreamingKVCache
|
| 7 |
+
from .core import (timestep_to_train_scale, ModulationCache, block_forward,
|
| 8 |
+
frame_forward, sequence_forward, make_rope_table, make_cache,
|
| 9 |
+
latent_geometry)
|
| 10 |
+
|
| 11 |
+
__all__ = ['RopeTable', 'apply_rope', 'StreamingKVCache',
|
| 12 |
+
'timestep_to_train_scale', 'ModulationCache', 'block_forward',
|
| 13 |
+
'frame_forward', 'sequence_forward', 'make_rope_table', 'make_cache',
|
| 14 |
+
'latent_geometry']
|
wanstreamer/blockcausal.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Blockcausal Wan forward shared by training and streaming inference.
|
| 2 |
+
|
| 3 |
+
One implementation serves both regimes, which is the point: what the student is
|
| 4 |
+
trained under is literally the code that runs at deployment.
|
| 5 |
+
|
| 6 |
+
inference BufferKV -> preallocated StreamingKVCache, no_grad, batch 1
|
| 7 |
+
training TrainKV -> plain per-layer tensors, autograd-safe, batched
|
| 8 |
+
|
| 9 |
+
The contract in both cases is the papers' "KV construction": the past is a
|
| 10 |
+
*clean* (t=0) key/value prefix laid down by an earlier pass, and the current
|
| 11 |
+
block is the only thing carrying noise. Block-causality is therefore structural
|
| 12 |
+
-- the key set only ever holds past + current -- so no attention mask is needed
|
| 13 |
+
over it, and adding one would wrongly serialise tokens within a block.
|
| 14 |
+
|
| 15 |
+
Two ways to run the noisy blocks of a clip, both against the same clean prefix:
|
| 16 |
+
|
| 17 |
+
block_forward one block, sequential. What inference does.
|
| 18 |
+
parallel_blocks_forward every block of the clip as a batch row, one forward.
|
| 19 |
+
Each row carries its own noise level and its own
|
| 20 |
+
absolute RoPE offset, and reads the prefix slice its
|
| 21 |
+
start index implies. Used for training.
|
| 22 |
+
|
| 23 |
+
PER-FRAME TIMESTEP CONDITIONING (v0.2). Upstream Wan folds one shared `e0` into
|
| 24 |
+
every token, so a sequence mixing a clean past with a noisy present is
|
| 25 |
+
inexpressible in a single forward. `time_embed` here accepts a per-latent-frame
|
| 26 |
+
vector, giving modulation [F, 6, dim] applied to x viewed as [B, F, S, dim].
|
| 27 |
+
With the pre-computed clean K/V above it is not *required*. The clean prefix
|
| 28 |
+
is laid down by its own t=0 pass -- but it is what makes a block of mixed noise
|
| 29 |
+
levels expressible at all, and a uniform scalar collapses to upstream's
|
| 30 |
+
behaviour exactly, so the original weights load and run unchanged.
|
| 31 |
+
"""
|
| 32 |
+
import torch
|
| 33 |
+
import torch.utils.checkpoint as ckpt
|
| 34 |
+
|
| 35 |
+
from wan.modules.attention import flash_attention
|
| 36 |
+
from wan.modules.model import sinusoidal_embedding_1d
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def time_embed(model, t, device, time_scale=1000.0):
|
| 40 |
+
"""Flow fraction(s) in [0,1] -> (e [N, dim], e0 [N, 6, dim]).
|
| 41 |
+
|
| 42 |
+
N == 1 uniform timestep (upstream behaviour)
|
| 43 |
+
N == batch rows one timestep per row
|
| 44 |
+
N == latent frames per-frame timestep conditioning (batch must be 1)
|
| 45 |
+
`time_scale` maps the internal [0,1] fraction onto the checkpoint's own
|
| 46 |
+
convention: 1000 for stock Wan, 1.0 for a model fine-tuned on torch.rand().
|
| 47 |
+
"""
|
| 48 |
+
if not torch.is_tensor(t):
|
| 49 |
+
t = torch.as_tensor([float(t)], device=device)
|
| 50 |
+
t = t.to(device=device, dtype=torch.float32).reshape(-1)
|
| 51 |
+
# The time embedding runs outside autocast so its precision is the module's
|
| 52 |
+
# own. Cast the input to match, rather than assuming fp32 weights: the
|
| 53 |
+
# frozen teacher / critic base is held in bf16 to fit three networks on one
|
| 54 |
+
# 40 GB card, and upstream's hard fp32 assumption would fail on it.
|
| 55 |
+
wd = next(model.time_embedding.parameters()).dtype
|
| 56 |
+
with torch.amp.autocast('cuda', enabled=False):
|
| 57 |
+
e = model.time_embedding(
|
| 58 |
+
sinusoidal_embedding_1d(model.freq_dim, t * time_scale).to(wd))
|
| 59 |
+
e0 = model.time_projection(e).unflatten(1, (6, model.dim))
|
| 60 |
+
return e, e0
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Modulation:
|
| 64 |
+
"""Per-layer AdaLN chunks for one set of timesteps.
|
| 65 |
+
|
| 66 |
+
`(block.modulation + e0).chunk(6)` is identical for every token of a frame,
|
| 67 |
+
so it is computed once per denoising step rather than per layer x frame.
|
| 68 |
+
Each chunk is [N, 1, dim].
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
def __init__(self, blocks, e0):
|
| 72 |
+
with torch.amp.autocast('cuda', enabled=False):
|
| 73 |
+
self.chunks = [(blk.modulation + e0).chunk(6, dim=1) for blk in blocks]
|
| 74 |
+
|
| 75 |
+
def __getitem__(self, i):
|
| 76 |
+
return self.chunks[i]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _bcast(g, x, per_frame, n_frames):
|
| 80 |
+
"""Broadcast a modulation chunk [N,1,dim] against tokens x [B,L,dim].
|
| 81 |
+
|
| 82 |
+
per_frame=False: N is 1 or B, which already broadcasts.
|
| 83 |
+
per_frame=True: N is the number of latent frames; tokens are frame-major,
|
| 84 |
+
so view x as [B, F, S, dim] and give g a leading axis.
|
| 85 |
+
"""
|
| 86 |
+
if not per_frame:
|
| 87 |
+
return g
|
| 88 |
+
return g.unsqueeze(0) # [1, F, 1, dim] against [B, F, S, dim]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _as_frames(x, per_frame, n_frames):
|
| 92 |
+
if not per_frame:
|
| 93 |
+
return x
|
| 94 |
+
b, l, d = x.shape
|
| 95 |
+
return x.view(b, n_frames, l // n_frames, d)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _modulate(x, norm, shift, scale, per_frame, n_frames):
|
| 99 |
+
y = _as_frames(norm(x).float(), per_frame, n_frames)
|
| 100 |
+
y = y * (1 + _bcast(scale, x, per_frame, n_frames)) \
|
| 101 |
+
+ _bcast(shift, x, per_frame, n_frames)
|
| 102 |
+
return y.reshape(x.shape[0], x.shape[1], x.shape[2]) if per_frame else y
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _gate(x, y, g, per_frame, n_frames):
|
| 106 |
+
"""x + y * g, in float32, with per-frame broadcasting."""
|
| 107 |
+
with torch.amp.autocast('cuda', dtype=torch.float32):
|
| 108 |
+
if not per_frame:
|
| 109 |
+
return x + y * g
|
| 110 |
+
b, l, d = x.shape
|
| 111 |
+
out = _as_frames(x, True, n_frames) + \
|
| 112 |
+
_as_frames(y, True, n_frames) * g.unsqueeze(0)
|
| 113 |
+
return out.reshape(b, l, d)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _head_from(head, x, e, per_frame, n_frames):
|
| 117 |
+
"""Split out from `_head` so the FSDP shard unit can enter it with the head
|
| 118 |
+
module it owns rather than reaching through the model (see wanstreamer.fsdp)."""
|
| 119 |
+
with torch.amp.autocast('cuda', enabled=False):
|
| 120 |
+
m = (head.modulation + e.unsqueeze(1)).chunk(2, dim=1)
|
| 121 |
+
y = _modulate(x, head.norm, m[0], m[1], per_frame, n_frames)
|
| 122 |
+
return head.head(y.to(head.head.weight.dtype))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _head(model, x, e, per_frame, n_frames):
|
| 126 |
+
return _head_from(model.head, x, e, per_frame, n_frames)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def apply_rope(x, tbl):
|
| 130 |
+
"""x [B, L, n, d] real -> rotated, by tbl [L, 1, c] (shared) or [B, L, 1, c].
|
| 131 |
+
|
| 132 |
+
float32 complex rather than upstream's float64; verified equivalent within
|
| 133 |
+
bf16 tolerance by tests/test_streaming_core.py.
|
| 134 |
+
"""
|
| 135 |
+
b, l, n, d = x.shape
|
| 136 |
+
xc = torch.view_as_complex(x.float().reshape(b, l, n, d // 2, 2))
|
| 137 |
+
if tbl.dim() == 3:
|
| 138 |
+
tbl = tbl.unsqueeze(0)
|
| 139 |
+
return torch.view_as_real(xc * tbl).flatten(3)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ------------------------------------------------------------------- K/V stores
|
| 143 |
+
class BufferKV:
|
| 144 |
+
"""Adapter over the preallocated StreamingKVCache used at inference."""
|
| 145 |
+
|
| 146 |
+
def __init__(self, cache):
|
| 147 |
+
self.cache = cache
|
| 148 |
+
|
| 149 |
+
def context(self, layer, k, v, **_):
|
| 150 |
+
self.cache.write(layer, k, v)
|
| 151 |
+
ck, cv = self.cache.context(layer, k.shape[1])
|
| 152 |
+
return ck, cv, None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class BufferPrefixKV:
|
| 156 |
+
"""Read-only slice of a StreamingKVCache, in TrainKV's calling convention.
|
| 157 |
+
|
| 158 |
+
The self-forcing trainer rolls out through the preallocated cache and then
|
| 159 |
+
has to redo one recorded denoising step *with gradient*, against the prefix
|
| 160 |
+
as it stood at that point. The buffer only ever grows during a rollout (no
|
| 161 |
+
eviction is used in training), so `buffer[:upto]` is exactly that prefix,
|
| 162 |
+
and reading it as a view keeps it detached for free.
|
| 163 |
+
"""
|
| 164 |
+
|
| 165 |
+
def __init__(self, cache):
|
| 166 |
+
self.cache = cache
|
| 167 |
+
|
| 168 |
+
def context(self, layer, k, v, upto=None, rows=1, k_lens=None):
|
| 169 |
+
if not upto:
|
| 170 |
+
return k, v, None
|
| 171 |
+
pk = self.cache.k[layer, :upto].unsqueeze(0)
|
| 172 |
+
pv = self.cache.v[layer, :upto].unsqueeze(0)
|
| 173 |
+
if rows > 1:
|
| 174 |
+
pk, pv = pk.expand(rows, -1, -1, -1), pv.expand(rows, -1, -1, -1)
|
| 175 |
+
return torch.cat([k, pk], 1), torch.cat([v, pv], 1), k_lens
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class TrainKV:
|
| 179 |
+
"""Clean per-layer K/V prefix for a whole clip: plain tensors, autograd-safe.
|
| 180 |
+
|
| 181 |
+
Built once per clip by `build_clean_kv` under no_grad; every noisy block then
|
| 182 |
+
reads the slice its start index implies, so each block trains against exactly
|
| 183 |
+
the prefix it would see at deployment.
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
def __init__(self, num_layers):
|
| 187 |
+
self.k = [None] * num_layers
|
| 188 |
+
self.v = [None] * num_layers
|
| 189 |
+
|
| 190 |
+
def append(self, layer, k, v):
|
| 191 |
+
if self.k[layer] is None:
|
| 192 |
+
self.k[layer], self.v[layer] = k, v
|
| 193 |
+
else:
|
| 194 |
+
self.k[layer] = torch.cat([self.k[layer], k], dim=1)
|
| 195 |
+
self.v[layer] = torch.cat([self.v[layer], v], dim=1)
|
| 196 |
+
|
| 197 |
+
@property
|
| 198 |
+
def tokens(self):
|
| 199 |
+
return 0 if self.k[0] is None else self.k[0].shape[1]
|
| 200 |
+
|
| 201 |
+
def context(self, layer, k, v, upto=None, rows=1, k_lens=None):
|
| 202 |
+
"""Keys are laid out [current block ; clean prefix] -- current FIRST.
|
| 203 |
+
|
| 204 |
+
That ordering is what makes the batched-block path expressible with a
|
| 205 |
+
key-*length* mask: row i must see its own block plus prefix[:start_i*S],
|
| 206 |
+
and only in this order are those two runs contiguous from index 0.
|
| 207 |
+
Attention is permutation-invariant over keys, so the sequential path
|
| 208 |
+
(which passes no mask at all) is unaffected.
|
| 209 |
+
"""
|
| 210 |
+
pk, pv = self.k[layer], self.v[layer]
|
| 211 |
+
if pk is None or upto == 0:
|
| 212 |
+
return k, v, None
|
| 213 |
+
pk, pv = pk[:, :upto], pv[:, :upto]
|
| 214 |
+
if rows > 1:
|
| 215 |
+
pk = pk.expand(rows, -1, -1, -1)
|
| 216 |
+
pv = pv.expand(rows, -1, -1, -1)
|
| 217 |
+
return torch.cat([k, pk], 1), torch.cat([v, pv], 1), k_lens
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------------------------- the layer
|
| 221 |
+
def _layer(blk, x, ec, tbl, kv_ctx, ctx, ctx_lens, dtype, per_frame, n_frames):
|
| 222 |
+
"""One WanAttentionBlock in block-causal mode. kv_ctx(k,v) -> (K, V, k_lens)."""
|
| 223 |
+
sa_in = _modulate(x, blk.norm1, ec[0], ec[1], per_frame, n_frames)
|
| 224 |
+
b, s = sa_in.shape[0], sa_in.shape[1]
|
| 225 |
+
n, d = blk.num_heads, blk.dim // blk.num_heads
|
| 226 |
+
sa = blk.self_attn
|
| 227 |
+
|
| 228 |
+
q = apply_rope(sa.norm_q(sa.q(sa_in)).view(b, s, n, d), tbl).to(dtype)
|
| 229 |
+
k = apply_rope(sa.norm_k(sa.k(sa_in)).view(b, s, n, d), tbl).to(dtype)
|
| 230 |
+
v = sa.v(sa_in).view(b, s, n, d).to(dtype)
|
| 231 |
+
|
| 232 |
+
ck, cv, k_lens = kv_ctx(k, v)
|
| 233 |
+
y = flash_attention(q=q, k=ck, v=cv, k_lens=k_lens,
|
| 234 |
+
window_size=(-1, -1), causal=False)
|
| 235 |
+
x = _gate(x, sa.o(y.flatten(2)), ec[2], per_frame, n_frames)
|
| 236 |
+
x = x + blk.cross_attn(blk.norm3(x), ctx, ctx_lens)
|
| 237 |
+
yf = blk.ffn(_modulate(x, blk.norm2, ec[3], ec[4], per_frame, n_frames))
|
| 238 |
+
return _gate(x, yf, ec[5], per_frame, n_frames)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _run(model, z, t, tbl, ctx, ctx_lens, kv_ctx_factory, dtype, time_scale,
|
| 242 |
+
per_frame, grad_checkpoint, emb=None):
|
| 243 |
+
"""Shared body: patch-embed -> 30 block-causal layers -> head -> unpatchify.
|
| 244 |
+
|
| 245 |
+
`emb` supplies a precomputed (e, e0). The CUDA-graph path needs it because
|
| 246 |
+
`sinusoidal_embedding_1d` builds its frequency vector on the CPU and copies
|
| 247 |
+
it to the device, which cannot be captured -- and the time embedding is two
|
| 248 |
+
small linears, so hoisting it out of the graph costs nothing.
|
| 249 |
+
"""
|
| 250 |
+
B, _, F = z.shape[0], z.shape[1], z.shape[2]
|
| 251 |
+
e, e0 = emb if emb is not None else time_embed(model, t, z.device, time_scale)
|
| 252 |
+
nf = F if per_frame else 1
|
| 253 |
+
# `wanstreamer.fsdp.shard_model` attaches these: per-layer nn.Modules that
|
| 254 |
+
# are the FSDP shard units. They must be ENTERED, because FSDP2 all-gathers
|
| 255 |
+
# a module's parameters from a pre-forward hook on that module -- reaching
|
| 256 |
+
# into `blk.self_attn.q` from outside, as `_layer` does, would run on
|
| 257 |
+
# sharded parameters without ever erroring. Absent them nothing changes.
|
| 258 |
+
layers = getattr(model, 'causal_layers', None)
|
| 259 |
+
head_mod = getattr(model, 'causal_head', None)
|
| 260 |
+
mod = Modulation(model.blocks, e0) if layers is None else None
|
| 261 |
+
with torch.amp.autocast('cuda', dtype=dtype):
|
| 262 |
+
x = model.patch_embedding(z.to(dtype))
|
| 263 |
+
gf, gh, gw = (int(s) for s in x.shape[2:])
|
| 264 |
+
x = x.flatten(2).transpose(1, 2)
|
| 265 |
+
for li in range(len(model.blocks)):
|
| 266 |
+
kv_ctx = kv_ctx_factory(li)
|
| 267 |
+
if layers is None:
|
| 268 |
+
fn = _layer
|
| 269 |
+
args = (model.blocks[li], x, mod[li], tbl, kv_ctx, ctx,
|
| 270 |
+
ctx_lens, dtype, per_frame, nf)
|
| 271 |
+
else:
|
| 272 |
+
# The sharded path passes e0 rather than a precomputed
|
| 273 |
+
# modulation chunk: `blk.modulation` is a plain Parameter read
|
| 274 |
+
# outside any forward by `Modulation`, so under FSDP it would
|
| 275 |
+
# still be a shard at that point. CausalBlock folds that one add
|
| 276 |
+
# into the layer, where the gather has already happened. It is
|
| 277 |
+
# the same arithmetic and the same number of evaluations -- each
|
| 278 |
+
# block's chunks are built exactly once per forward either way.
|
| 279 |
+
fn = layers[li]
|
| 280 |
+
args = (x, e0, tbl, kv_ctx, ctx, ctx_lens, dtype, per_frame, nf)
|
| 281 |
+
if grad_checkpoint and torch.is_grad_enabled():
|
| 282 |
+
x = ckpt.checkpoint(fn, *args, use_reentrant=False)
|
| 283 |
+
else:
|
| 284 |
+
x = fn(*args)
|
| 285 |
+
h = head_mod(x, e, per_frame, nf) if head_mod is not None \
|
| 286 |
+
else _head(model, x, e, per_frame, nf)
|
| 287 |
+
return _unpatchify(model, h, gf, gh, gw)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _unpatchify(model, x, gf, gh, gw):
|
| 291 |
+
"""[B, L, out_dim*prod(patch)] -> [B, C, F, H, W].
|
| 292 |
+
|
| 293 |
+
Upstream's `WanModel.unpatchify` takes the grid as a *tensor* and calls
|
| 294 |
+
`.tolist()` on it, which builds a CPU tensor and syncs -- neither is legal
|
| 295 |
+
inside a CUDA graph capture. The grid is statically known here (it comes
|
| 296 |
+
from the patch-embedding output shape), so do the same reshape with Python
|
| 297 |
+
ints. Equivalence to upstream is asserted in scripts/verify_blockcausal.py.
|
| 298 |
+
"""
|
| 299 |
+
p0, p1, p2 = model.patch_size
|
| 300 |
+
c = model.out_dim
|
| 301 |
+
b = x.shape[0]
|
| 302 |
+
u = x[:, :gf * gh * gw].view(b, gf, gh, gw, p0, p1, p2, c)
|
| 303 |
+
u = u.permute(0, 7, 1, 4, 2, 5, 3, 6) # b c f p0 h p1 w p2
|
| 304 |
+
return u.reshape(b, c, gf * p0, gh * p1, gw * p2)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# --------------------------------------------------------------- entry points
|
| 308 |
+
def block_forward(model, z, t, t_start, rope, ctx, ctx_lens, kv=None,
|
| 309 |
+
collect=None, dtype=torch.bfloat16, time_scale=1000.0,
|
| 310 |
+
prefix_upto=None, per_frame=False, grad_checkpoint=False,
|
| 311 |
+
tbl=None, emb=None):
|
| 312 |
+
"""Velocity for ONE block of latent frames against the clean prefix.
|
| 313 |
+
|
| 314 |
+
z [B, C, F, H, W] noisy latents of the current block
|
| 315 |
+
t float, or [F] flow fractions in [0,1] when per_frame
|
| 316 |
+
t_start absolute latent-frame index of z[:, :, 0] (drives temporal RoPE)
|
| 317 |
+
kv BufferKV (inference) | TrainKV (training) | None (self-attention only)
|
| 318 |
+
collect list receiving this block's per-layer (k, v), or None
|
| 319 |
+
tbl precomputed RoPE table; overrides t_start. The CUDA-graph path
|
| 320 |
+
supplies it from a static buffer, since it is the one input that
|
| 321 |
+
changes with the absolute frame index.
|
| 322 |
+
"""
|
| 323 |
+
if tbl is None:
|
| 324 |
+
tbl = rope.span(t_start, z.shape[2])
|
| 325 |
+
|
| 326 |
+
def factory(li):
|
| 327 |
+
def kv_ctx(k, v):
|
| 328 |
+
if collect is not None:
|
| 329 |
+
collect.append((k, v))
|
| 330 |
+
if kv is None:
|
| 331 |
+
return k, v, None
|
| 332 |
+
if prefix_upto is None:
|
| 333 |
+
return kv.context(li, k, v)
|
| 334 |
+
return kv.context(li, k, v, upto=prefix_upto)
|
| 335 |
+
return kv_ctx
|
| 336 |
+
|
| 337 |
+
return _run(model, z, t, tbl, ctx, ctx_lens, factory, dtype, time_scale,
|
| 338 |
+
per_frame, grad_checkpoint, emb=emb)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@torch.no_grad()
|
| 342 |
+
def build_clean_kv(model, z, rope, ctx, ctx_lens, world_frames, block_frames,
|
| 343 |
+
dtype=torch.bfloat16, time_scale=1000.0, rope_gap=0):
|
| 344 |
+
"""Clean (t=0) K/V for a whole clip, laid down exactly as deployment lays it
|
| 345 |
+
down: the world primed as one bidirectional block, then each event block
|
| 346 |
+
committed after being re-run at t=0. z: [B, C, F, H, W] clean latents.
|
| 347 |
+
|
| 348 |
+
`rope_gap` shifts the *event* frames' temporal indices further from the
|
| 349 |
+
world's -- see `event_rope_index`.
|
| 350 |
+
"""
|
| 351 |
+
kv = TrainKV(len(model.blocks))
|
| 352 |
+
F, S = z.shape[2], rope.seq
|
| 353 |
+
spans, f = ([(0, world_frames)] if world_frames else []), world_frames
|
| 354 |
+
while f < F:
|
| 355 |
+
n = min(block_frames, F - f)
|
| 356 |
+
spans.append((f, n))
|
| 357 |
+
f += n
|
| 358 |
+
for t0, n in spans:
|
| 359 |
+
collect = []
|
| 360 |
+
idx = t0 if t0 < world_frames or t0 == 0 else t0 + rope_gap
|
| 361 |
+
block_forward(model, z[:, :, t0:t0 + n], 0.0, idx, rope, ctx, ctx_lens,
|
| 362 |
+
kv=kv, collect=collect, dtype=dtype,
|
| 363 |
+
time_scale=time_scale, prefix_upto=t0 * S)
|
| 364 |
+
for li, (k, v) in enumerate(collect):
|
| 365 |
+
kv.append(li, k, v)
|
| 366 |
+
return kv
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def event_rope_index(frame, world_frames, rope_gap):
|
| 370 |
+
"""Absolute temporal index for RoPE, with the world pushed into the past.
|
| 371 |
+
|
| 372 |
+
RoPE is relative: attention depends only on index differences. In a long
|
| 373 |
+
stream the event window slides but the world block is *pinned*, so by unit
|
| 374 |
+
60 the current query sits ~180 latent frames from the world -- a relative
|
| 375 |
+
distance training never showed the model if a clip is only 21 frames long,
|
| 376 |
+
and rotary attention degrades badly off-distribution.
|
| 377 |
+
|
| 378 |
+
Deployment produces that geometry for free (evicted events leave a real gap
|
| 379 |
+
in the cache). Training has to simulate it, which costs nothing: keep the
|
| 380 |
+
world at 0..world_frames and shift every event frame by a random gap. The
|
| 381 |
+
content stays contiguous; only the positional distance changes, which is
|
| 382 |
+
exactly the axis that needs covering.
|
| 383 |
+
"""
|
| 384 |
+
return frame if frame < world_frames else frame + rope_gap
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def parallel_blocks_forward(model, z_noisy, t_rows, starts, rope, ctx, ctx_lens,
|
| 388 |
+
kv, block_frames, dtype=torch.bfloat16,
|
| 389 |
+
time_scale=1000.0, grad_checkpoint=True):
|
| 390 |
+
"""Every event block of a clip in ONE forward, as batch rows.
|
| 391 |
+
|
| 392 |
+
Row i holds block `starts[i]`, carries its own noise level `t_rows[i]` and
|
| 393 |
+
its own absolute RoPE offset, and attends over
|
| 394 |
+
[clean prefix[:starts[i]*S] ; its own noisy tokens]. Because the visible key
|
| 395 |
+
set depends only on the row -- never on the query index -- this costs a
|
| 396 |
+
[nb, 1, 1, Lk] key-padding mask instead of a dense [Lq, Lk] one (see
|
| 397 |
+
sdpa_attention's key-padding fast path).
|
| 398 |
+
|
| 399 |
+
z_noisy [1, C, F, H, W] full-clip noisy latents; returns [nb, C, b, H, W].
|
| 400 |
+
"""
|
| 401 |
+
S, nb = rope.seq, len(starts)
|
| 402 |
+
blocks = torch.cat([z_noisy[:, :, s:s + block_frames] for s in starts], 0)
|
| 403 |
+
tbl = torch.stack([rope.span(s, block_frames).squeeze(1) for s in starts]
|
| 404 |
+
).unsqueeze(2) # [nb, b*S, 1, c]
|
| 405 |
+
max_prefix = max(starts) * S
|
| 406 |
+
# keys are [own block ; clean prefix], so row i keeps the first
|
| 407 |
+
# block_frames*S + starts[i]*S of them (see TrainKV.context)
|
| 408 |
+
k_lens = torch.tensor([block_frames * S + s * S for s in starts],
|
| 409 |
+
device=z_noisy.device, dtype=torch.long)
|
| 410 |
+
ctx_b = ctx.expand(nb, -1, -1) if ctx.shape[0] == 1 else ctx
|
| 411 |
+
cl = ctx_lens.expand(nb) if ctx_lens.numel() == 1 else ctx_lens
|
| 412 |
+
|
| 413 |
+
def factory(li):
|
| 414 |
+
return lambda k, v: kv.context(li, k, v, upto=max_prefix, rows=nb,
|
| 415 |
+
k_lens=k_lens)
|
| 416 |
+
|
| 417 |
+
return _run(model, blocks, t_rows, tbl, ctx_b, cl, factory, dtype,
|
| 418 |
+
time_scale, False, grad_checkpoint)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def block_starts(num_frames, world_frames, block_frames):
|
| 422 |
+
return [s for s in range(world_frames, num_frames, block_frames)
|
| 423 |
+
if s + block_frames <= num_frames]
|
wanstreamer/core.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Block-causal streaming forward for Wan2.1, corrected and de-overheaded.
|
| 2 |
+
|
| 3 |
+
Corrections relative to streaming_blocks.py, each backed by a measurement
|
| 4 |
+
recorded in PROGRESS.md:
|
| 5 |
+
|
| 6 |
+
1. TIMESTEP SCALE. final.pt was fine-tuned with t in [0,1]
|
| 7 |
+
(train_streaming.py:152 draws torch.rand()), not the scheduler's [0,1000].
|
| 8 |
+
Callers must pass t in [0,1]; `timestep_to_train_scale` does the conversion.
|
| 9 |
+
Measured: normalised flow error 0.499 -> 0.168.
|
| 10 |
+
2. TEMPORAL RoPE. The old path gave every frame temporal index 0. Here each
|
| 11 |
+
latent frame is rotated at its true absolute index, so cached keys carry
|
| 12 |
+
real temporal position. Measured: 0.180 -> 0.107 (uniform, t=0.5).
|
| 13 |
+
3. NO CAUSAL MASK. The K/V cache holds only past+current frames, so full
|
| 14 |
+
attention over it is already block-causal. The old code passed causal=True
|
| 15 |
+
with Lq != Lk, which imposes a spurious raster ordering *within* a frame.
|
| 16 |
+
Measured: slightly better error AND 1.56x faster (no mask materialisation).
|
| 17 |
+
|
| 18 |
+
Overhead reductions, targeting the profile
|
| 19 |
+
"""
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
from wan.modules.attention import flash_attention
|
| 23 |
+
|
| 24 |
+
from .rope import RopeTable, apply_rope
|
| 25 |
+
from .kvcache import StreamingKVCache
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def timestep_to_train_scale(t_val, num_train_timesteps=1000):
|
| 29 |
+
"""Scheduler timestep (0..1000) -> the t in [0,1] final.pt was trained on."""
|
| 30 |
+
return float(t_val) / float(num_train_timesteps)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ModulationCache:
|
| 34 |
+
"""Per-block AdaLN modulation chunks for one timestep.
|
| 35 |
+
|
| 36 |
+
`(block.modulation + e0).chunk(6)` is identical for every frame at a given
|
| 37 |
+
denoising step, but the old code recomputed it per block *per frame*.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(self, blocks, e0):
|
| 41 |
+
self.chunks = []
|
| 42 |
+
with torch.amp.autocast('cuda', dtype=torch.float32):
|
| 43 |
+
for blk in blocks:
|
| 44 |
+
self.chunks.append((blk.modulation + e0).chunk(6, dim=1))
|
| 45 |
+
|
| 46 |
+
def __getitem__(self, i):
|
| 47 |
+
return self.chunks[i]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def block_forward(blk, x, mod, rope_tbl, t_index, cache, layer, ctx, ctx_lens):
|
| 51 |
+
"""One WanAttentionBlock in block-causal streaming mode.
|
| 52 |
+
|
| 53 |
+
x: [B, S, dim] tokens of the CURRENT latent frame only.
|
| 54 |
+
Returns the updated x. K/V for this frame is written (uncommitted) to `cache`.
|
| 55 |
+
"""
|
| 56 |
+
ec = mod
|
| 57 |
+
sa_in = blk.norm1(x).float() * (1 + ec[1]) + ec[0]
|
| 58 |
+
|
| 59 |
+
b, s = sa_in.shape[0], sa_in.shape[1]
|
| 60 |
+
n = blk.num_heads
|
| 61 |
+
d = blk.dim // n
|
| 62 |
+
sa = blk.self_attn
|
| 63 |
+
|
| 64 |
+
q = sa.norm_q(sa.q(sa_in)).view(b, s, n, d)
|
| 65 |
+
k = sa.norm_k(sa.k(sa_in)).view(b, s, n, d)
|
| 66 |
+
v = sa.v(sa_in).view(b, s, n, d)
|
| 67 |
+
|
| 68 |
+
tbl = rope_tbl.frame(t_index)
|
| 69 |
+
q = apply_rope(q, tbl)
|
| 70 |
+
k = apply_rope(k, tbl)
|
| 71 |
+
|
| 72 |
+
# Write current chunk, then attend over past+current as one view.
|
| 73 |
+
cache.write(layer, k.to(cache.k.dtype), v.to(cache.v.dtype))
|
| 74 |
+
ctx_k, ctx_v = cache.context(layer, s)
|
| 75 |
+
|
| 76 |
+
y = flash_attention(q=q.to(ctx_k.dtype), k=ctx_k, v=ctx_v,
|
| 77 |
+
window_size=(-1, -1), causal=False)
|
| 78 |
+
y = sa.o(y.flatten(2))
|
| 79 |
+
|
| 80 |
+
with torch.amp.autocast('cuda', dtype=torch.float32):
|
| 81 |
+
x = x + y * ec[2]
|
| 82 |
+
x = x + blk.cross_attn(blk.norm3(x), ctx, ctx_lens)
|
| 83 |
+
y = blk.ffn(blk.norm2(x).float() * (1 + ec[4]) + ec[3])
|
| 84 |
+
with torch.amp.autocast('cuda', dtype=torch.float32):
|
| 85 |
+
x = x + y * ec[5]
|
| 86 |
+
return x
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@torch.no_grad()
|
| 90 |
+
def frame_forward(model, latent_frame, e, mod, rope_tbl, t_index, cache,
|
| 91 |
+
ctx, ctx_lens):
|
| 92 |
+
"""Denoise ONE latent frame against the cached clean past.
|
| 93 |
+
|
| 94 |
+
latent_frame: [B, C, 1, H, W]. Returns predicted velocity [C, 1, H, W].
|
| 95 |
+
Does not commit the frame's K/V -- the caller commits once the chunk is final.
|
| 96 |
+
"""
|
| 97 |
+
x = model.patch_embedding(latent_frame)
|
| 98 |
+
grid = torch.stack([torch.tensor(x.shape[2:], dtype=torch.long, device=x.device)
|
| 99 |
+
for _ in range(x.shape[0])])
|
| 100 |
+
x = x.flatten(2).transpose(1, 2)
|
| 101 |
+
|
| 102 |
+
for i, blk in enumerate(model.blocks):
|
| 103 |
+
x = block_forward(blk, x, mod[i], rope_tbl, t_index, cache, i, ctx, ctx_lens)
|
| 104 |
+
|
| 105 |
+
return model.unpatchify(model.head(x, e), grid)[0]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@torch.no_grad()
|
| 109 |
+
def sequence_forward(model, latents, e, mod, rope_tbl, cache, ctx, ctx_lens,
|
| 110 |
+
start_index=0, commit=True):
|
| 111 |
+
"""Run a run of latent frames causally, committing each as it completes.
|
| 112 |
+
|
| 113 |
+
Used to (a) prime the cache from clean context frames and (b) evaluate the
|
| 114 |
+
model over a whole sequence for diagnostics. latents: [B, C, F, H, W].
|
| 115 |
+
"""
|
| 116 |
+
outs = []
|
| 117 |
+
for f in range(latents.shape[2]):
|
| 118 |
+
out = frame_forward(model, latents[:, :, f:f + 1], e, mod, rope_tbl,
|
| 119 |
+
start_index + f, cache, ctx, ctx_lens)
|
| 120 |
+
outs.append(out)
|
| 121 |
+
if commit:
|
| 122 |
+
cache.commit(rope_tbl.seq)
|
| 123 |
+
return torch.cat(outs, dim=1)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def make_rope_table(model, h_patches, w_patches, max_frames, device):
|
| 127 |
+
return RopeTable(model.freqs.to(device), h_patches, w_patches, max_frames, device)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def make_cache(model, tokens_per_frame, max_frames, device, dtype=torch.bfloat16):
|
| 131 |
+
n = model.num_heads
|
| 132 |
+
d = model.dim // n
|
| 133 |
+
return StreamingKVCache(
|
| 134 |
+
num_layers=len(model.blocks),
|
| 135 |
+
max_tokens=tokens_per_frame * max_frames,
|
| 136 |
+
num_heads=n, head_dim=d, device=device, dtype=dtype,
|
| 137 |
+
scratch_tokens=tokens_per_frame)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def latent_geometry(width, height, vae_stride=(4, 8, 8), patch_size=(1, 2, 2)):
|
| 141 |
+
"""Pixel (W,H) -> latent (H_lat, W_lat) and patch grid (H_p, W_p).
|
| 142 |
+
|
| 143 |
+
Mirrors WanModel/run_demo: target_shape[2] = size[1]//vae_stride[1] (height),
|
| 144 |
+
target_shape[3] = size[0]//vae_stride[2] (width).
|
| 145 |
+
"""
|
| 146 |
+
h_lat = height // vae_stride[1]
|
| 147 |
+
w_lat = width // vae_stride[2]
|
| 148 |
+
if h_lat % patch_size[1] or w_lat % patch_size[2]:
|
| 149 |
+
raise ValueError(f'{width}x{height} -> latent {h_lat}x{w_lat} not '
|
| 150 |
+
f'divisible by patch size {patch_size[1:]}')
|
| 151 |
+
return h_lat, w_lat, h_lat // patch_size[1], w_lat // patch_size[2]
|
wanstreamer/data.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Teacher-latent dataset plus the shared flow-matching conventions."""
|
| 2 |
+
import glob
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch.utils.data import Dataset
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TeacherLatents(Dataset):
|
| 10 |
+
"""Teacher ODE samples: clean latents [16, F, H, W] + their prompt index."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, root, prompts_path, frames=None):
|
| 13 |
+
self.files = sorted(glob.glob(os.path.join(root, '*.pt')))
|
| 14 |
+
if not self.files:
|
| 15 |
+
raise RuntimeError(f'no .pt latents under {root}')
|
| 16 |
+
blob = torch.load(prompts_path, map_location='cpu')
|
| 17 |
+
self.pos = blob['pos'] # [P, 512, 4096] fp16
|
| 18 |
+
self.neg = blob['neg'] # [1, 512, 4096] fp16
|
| 19 |
+
self.prompts = blob['prompts']
|
| 20 |
+
self.frames = frames
|
| 21 |
+
|
| 22 |
+
def __len__(self):
|
| 23 |
+
return len(self.files)
|
| 24 |
+
|
| 25 |
+
def __getitem__(self, i):
|
| 26 |
+
d = torch.load(self.files[i], map_location='cpu', weights_only=False)
|
| 27 |
+
lat = d['latents'].float()
|
| 28 |
+
if self.frames is not None:
|
| 29 |
+
lat = lat[:, :self.frames]
|
| 30 |
+
pi = int(d['prompt_idx'])
|
| 31 |
+
return lat, self.pos[pi].float(), pi
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def shifted_uniform_t(n, shift=5.0, device='cpu', generator=None):
|
| 35 |
+
"""Flow fractions distributed as Wan's inference sigma schedule.
|
| 36 |
+
|
| 37 |
+
Wan samples with shift=5, which concentrates steps near t=1. Training with
|
| 38 |
+
plain U(0,1) would under-serve exactly the region the student spends most of
|
| 39 |
+
its few steps in, so draw from the same reparameterisation the sampler uses:
|
| 40 |
+
t = shift*u / (1 + (shift-1)*u), u ~ U(0,1).
|
| 41 |
+
"""
|
| 42 |
+
u = torch.rand(n, device=device, generator=generator)
|
| 43 |
+
return shift * u / (1 + (shift - 1) * u)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def add_noise(z0, t, noise=None):
|
| 47 |
+
"""Wan's rectified-flow convention, matching train_streaming.py:
|
| 48 |
+
|
| 49 |
+
z_t = (1 - t) * z0 + t * noise
|
| 50 |
+
target = noise - z0 (velocity the model predicts)
|
| 51 |
+
|
| 52 |
+
t broadcasts over [B] or [B, F]; z0 is [B, C, F, H, W].
|
| 53 |
+
"""
|
| 54 |
+
if noise is None:
|
| 55 |
+
noise = torch.randn_like(z0)
|
| 56 |
+
while t.dim() < z0.dim():
|
| 57 |
+
t = t.unsqueeze(-1)
|
| 58 |
+
return (1 - t) * z0 + t * noise, noise - z0, noise
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def x0_from_velocity(z_t, v, t):
|
| 62 |
+
"""Invert the flow parameterisation: z0 = z_t - t * v."""
|
| 63 |
+
while t.dim() < z_t.dim():
|
| 64 |
+
t = t.unsqueeze(-1)
|
| 65 |
+
return z_t - t * v
|
wanstreamer/dmd.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Distribution Matching Distillation for the blockcausal student.
|
| 2 |
+
|
| 3 |
+
The generator loss is the DMD2 form. Given a generated clean latent `x0`, noise
|
| 4 |
+
it to some level t and ask two score networks where they think it came from:
|
| 5 |
+
|
| 6 |
+
x0_real = teacher(x_t, t) with CFG -- the distribution we want
|
| 7 |
+
x0_fake = critic(x_t, t) -- the distribution we have
|
| 8 |
+
grad = (x0_fake - x0_real) / normalizer
|
| 9 |
+
L_gen = 0.5 * || x0 - stopgrad(x0 - grad) ||^2
|
| 10 |
+
|
| 11 |
+
so d L_gen / d x0 == grad, which is the KL gradient between the two
|
| 12 |
+
distributions. The normalizer is DMD2 scale invariant one. Which is mean |x0 - x0_real|
|
| 13 |
+
. Without which the loss magnitude swings by orders of magnitude across t and
|
| 14 |
+
the generator LR cannot be set at all.
|
| 15 |
+
|
| 16 |
+
The critic is trained in the ordinary way, a flow-matching loss on the student's
|
| 17 |
+
own samples, so it tracks a moving target. That is the whole reason DMD needs
|
| 18 |
+
two time scales: the critic must stay ahead of the generator.
|
| 19 |
+
|
| 20 |
+
Why the teacher is allowed to be bidirectional: it only ever scores a finished
|
| 21 |
+
clip, never generates one. Causality is a constraint on the students sampling
|
| 22 |
+
procedure, not on the reference distribution.
|
| 23 |
+
"""
|
| 24 |
+
import torch
|
| 25 |
+
import torch.nn.functional as tnnF
|
| 26 |
+
|
| 27 |
+
from . import blockcausal as bc
|
| 28 |
+
from .data import add_noise
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def bidirectional_velocity(model, z, t, rope, ctx, ctx_lens, dtype,
|
| 32 |
+
time_scale=1000.0, grad_checkpoint=False):
|
| 33 |
+
"""Full bidirectional forward over a whole clip.
|
| 34 |
+
|
| 35 |
+
`block_forward` with no K/V prefix and the entire clip as one block *is* the
|
| 36 |
+
bidirectional forward -- attention runs over the clip and nothing else -- so
|
| 37 |
+
teacher, critic and student all go through one code path and one RoPE
|
| 38 |
+
implementation.
|
| 39 |
+
"""
|
| 40 |
+
return bc.block_forward(model, z, t, 0, rope, ctx, ctx_lens, kv=None,
|
| 41 |
+
dtype=dtype, time_scale=time_scale,
|
| 42 |
+
grad_checkpoint=grad_checkpoint)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cfg_velocity(model, z, t, rope, ctx_pos, ctx_neg, ctx_lens, dtype,
|
| 46 |
+
guidance, time_scale=1000.0):
|
| 47 |
+
"""Teacher velocity with classifier-free guidance, cond and uncond batched."""
|
| 48 |
+
if not guidance or guidance == 1.0:
|
| 49 |
+
return bidirectional_velocity(model, z, t, rope, ctx_pos, ctx_lens,
|
| 50 |
+
dtype, time_scale)
|
| 51 |
+
z2 = torch.cat([z, z], 0)
|
| 52 |
+
ctx2 = torch.cat([ctx_pos, ctx_neg], 0)
|
| 53 |
+
lens2 = ctx_lens.expand(2)
|
| 54 |
+
v = bidirectional_velocity(model, z2, t, rope, ctx2, lens2, dtype, time_scale)
|
| 55 |
+
vc, vu = v[0:1], v[1:2]
|
| 56 |
+
return vu + guidance * (vc - vu)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@torch.no_grad()
|
| 60 |
+
def dmd_gradient(x0, t, z_t, v_real, v_fake):
|
| 61 |
+
"""DMD2's KL gradient wrt the generated clip, for the whole clip at once.
|
| 62 |
+
|
| 63 |
+
One normalizer for the clip rather than one per block: per-block
|
| 64 |
+
normalisation would rescale each block's gradient by its own error and so
|
| 65 |
+
quietly up-weight whichever block is currently worst.
|
| 66 |
+
"""
|
| 67 |
+
while t.dim() < z_t.dim():
|
| 68 |
+
t = t.unsqueeze(-1)
|
| 69 |
+
x0_real = (z_t - t * v_real).float()
|
| 70 |
+
x0_fake = (z_t - t * v_fake).float()
|
| 71 |
+
normalizer = (x0.float() - x0_real).abs().mean().clamp_min(1e-4)
|
| 72 |
+
grad = torch.nan_to_num((x0_fake - x0_real) / normalizer)
|
| 73 |
+
return grad, {'dmd_grad_norm': float(grad.norm()),
|
| 74 |
+
'dmd_normalizer': float(normalizer),
|
| 75 |
+
'dmd_real_fake_gap': float((x0_real - x0_fake).abs().mean())}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def dmd_surrogate(x0_hat, grad, scale=1.0):
|
| 79 |
+
"""Scalar whose gradient wrt x0_hat is exactly `grad` (times scale)."""
|
| 80 |
+
return scale * 0.5 * tnnF.mse_loss(x0_hat.float(),
|
| 81 |
+
(x0_hat.detach() - grad).float())
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def critic_loss(model, x0, t, rope, ctx, ctx_lens, dtype, time_scale=1000.0,
|
| 85 |
+
grad_checkpoint=True, noise=None):
|
| 86 |
+
"""Flow-matching loss for the fake-score network on student samples."""
|
| 87 |
+
z_t, target, _ = add_noise(x0, t, noise)
|
| 88 |
+
v = bidirectional_velocity(model, z_t, t, rope, ctx, ctx_lens, dtype,
|
| 89 |
+
time_scale, grad_checkpoint=grad_checkpoint)
|
| 90 |
+
return tnnF.mse_loss(v.float(), target.float())
|
wanstreamer/fsdp.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FSDP2 sharding for the block-causal student, and why it needs help.
|
| 2 |
+
|
| 3 |
+
Both trainers here are data-parallel today: Stage 1 wraps its step in a module
|
| 4 |
+
so DDP's hooks fire, and DMD all-reduces gradients by hand. Both keep a full
|
| 5 |
+
fp32 copy of the model, its gradients and its AdamW moments on every GPU --
|
| 6 |
+
16 bytes per parameter, 21 GB at 1.3B, and 224 GB at 14B, which is why nothing
|
| 7 |
+
larger than 1.3B trains on this box at all. Sharding those three tensor sets
|
| 8 |
+
across N ranks is the only thing that changes that, so it has to work before a
|
| 9 |
+
larger base is worth discussing.
|
| 10 |
+
|
| 11 |
+
THE TRAP THIS MODULE EXISTS FOR. FSDP2 all-gathers a sharded module's
|
| 12 |
+
parameters in a pre-forward hook installed on *that module*. `blockcausal._run`
|
| 13 |
+
never calls a `WanAttentionBlock` -- it passes the block to `_layer`, which
|
| 14 |
+
reaches into `blk.self_attn.q`, `blk.norm1`, `blk.ffn` and so on. Shard at the
|
| 15 |
+
block and that hook never fires, so the layer runs against parameters that are
|
| 16 |
+
still 1/N shards. It is structurally the same mistake DDP already invites here
|
| 17 |
+
("DDP does not sync gradients if you call the bare model
|
| 18 |
+
through helper functions"), and it is why this port is worth debugging at 1.3B.
|
| 19 |
+
|
| 20 |
+
Measured, on this codebase, the mis-shard is LOUD rather than silent: the first
|
| 21 |
+
parameter touched outside a forward is `blk.modulation`, and DTensor refuses
|
| 22 |
+
the mixed operand
|
| 23 |
+
|
| 24 |
+
RuntimeError: aten.add.Tensor got mixed torch.Tensor and DTensor
|
| 25 |
+
|
| 26 |
+
(scripts/verify_fsdp.py check 3 asserts exactly this). That guard is DTensor's,
|
| 27 |
+
not ours, and it only holds while every parameter stays a DTensor; it is not a
|
| 28 |
+
reason to leave the shard boundary in the wrong place. The `stragglers` check
|
| 29 |
+
in `shard_model` is the part that does not depend on someone else's invariant.
|
| 30 |
+
|
| 31 |
+
`CausalBlock` and `CausalHead` fix it by being real modules whose forward *is*
|
| 32 |
+
the computation, so the shard boundary and the call boundary coincide.
|
| 33 |
+
`scripts/verify_fsdp.py` is the control: it asserts a sharded forward matches
|
| 34 |
+
the single-GPU forward, and that a deliberately mis-sharded one does not.
|
| 35 |
+
|
| 36 |
+
What is sharded: the 30 transformer blocks (98% of parameters) plus, when they
|
| 37 |
+
are big enough to matter, the head and the embeddings -- all of which are
|
| 38 |
+
entered through their own `__call__` and so need no wrapper.
|
| 39 |
+
"""
|
| 40 |
+
import torch
|
| 41 |
+
from torch.distributed.checkpoint.state_dict import (
|
| 42 |
+
get_model_state_dict, StateDictOptions
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
from . import blockcausal as bc
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class CausalBlock(torch.nn.Module):
|
| 49 |
+
"""One WanAttentionBlock as an FSDP shard unit.
|
| 50 |
+
|
| 51 |
+
Holds the block as a child so `fully_shard(self)` shards the block's
|
| 52 |
+
parameters, and runs `_layer` in its own forward so entering the shard is
|
| 53 |
+
the same act as entering the computation.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, blk):
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.blk = blk
|
| 59 |
+
|
| 60 |
+
def forward(self, x, e0, tbl, kv_ctx, ctx, ctx_lens, dtype, per_frame,
|
| 61 |
+
n_frames):
|
| 62 |
+
with torch.amp.autocast('cuda', enabled=False):
|
| 63 |
+
ec = (self.blk.modulation + e0).chunk(6, dim=1)
|
| 64 |
+
return bc._layer(self.blk, x, ec, tbl, kv_ctx, ctx, ctx_lens, dtype,
|
| 65 |
+
per_frame, n_frames)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class CausalHead(torch.nn.Module):
|
| 69 |
+
"""The output head as an FSDP shard unit, for the same reason: `_head`
|
| 70 |
+
reaches through `model.head` to `model.head.head` and reads
|
| 71 |
+
`model.head.modulation` directly."""
|
| 72 |
+
|
| 73 |
+
def __init__(self, head):
|
| 74 |
+
super().__init__()
|
| 75 |
+
self.head = head
|
| 76 |
+
|
| 77 |
+
def forward(self, x, e, per_frame, n_frames):
|
| 78 |
+
return bc._head_from(self.head, x, e, per_frame, n_frames)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def attach_causal_modules(model):
|
| 82 |
+
"""Install the per-layer / head modules `blockcausal._run` will enter, and
|
| 83 |
+
make each of them the *only* registered path to its parameters.
|
| 84 |
+
|
| 85 |
+
Re-registering is the subtle half. `CausalBlock(blk)` holds `blk` as a
|
| 86 |
+
child, but `model.blocks[i]` still holds it too, so every block parameter
|
| 87 |
+
now has two names in the module tree. FSDP2 shards by walking that tree and
|
| 88 |
+
skipping what a child FSDP module already owns; reached by its second name
|
| 89 |
+
a parameter looks unmanaged, and sharding it again dies with "Cannot
|
| 90 |
+
concatenate overlapping meshes". So `blocks` and `head` are demoted to
|
| 91 |
+
plain attributes -- still there for `len(model.blocks)` and for the
|
| 92 |
+
unsharded code path, invisible to `named_parameters()`.
|
| 93 |
+
|
| 94 |
+
Separated from `shard_model` so the correctness control can run the wrapped
|
| 95 |
+
path *without* FSDP and show that wrapping alone changes nothing.
|
| 96 |
+
"""
|
| 97 |
+
if getattr(model, 'causal_layers', None) is not None:
|
| 98 |
+
return model
|
| 99 |
+
blocks = list(model.blocks)
|
| 100 |
+
head = model.head
|
| 101 |
+
layers = torch.nn.ModuleList(CausalBlock(b) for b in blocks)
|
| 102 |
+
chead = CausalHead(head)
|
| 103 |
+
del model.blocks, model.head # drop from _modules
|
| 104 |
+
model.causal_layers = layers
|
| 105 |
+
model.causal_head = chead
|
| 106 |
+
# object.__setattr__, because nn.Module.__setattr__ would re-register an
|
| 107 |
+
# nn.Module value and undo exactly what the `del` above achieved.
|
| 108 |
+
object.__setattr__(model, 'blocks', blocks)
|
| 109 |
+
object.__setattr__(model, 'head', head)
|
| 110 |
+
return model
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def shard_model(model, mesh=None, reshard_after_forward=True, mp_policy=None,
|
| 114 |
+
ignored_params=None):
|
| 115 |
+
"""Shard a WanModel across `mesh` with FSDP2, in place. Returns the model.
|
| 116 |
+
|
| 117 |
+
Call BEFORE moving to device and before building the optimizer: FSDP2
|
| 118 |
+
replaces `.weight` with a DTensor, and an optimizer built over the
|
| 119 |
+
unsharded parameters would hold stale references.
|
| 120 |
+
|
| 121 |
+
`reshard_after_forward=False` keeps parameters gathered between forward and
|
| 122 |
+
backward. That trades memory for collectives, and it matters here more than
|
| 123 |
+
in ordinary training: one DMD iteration runs the student a few dozen times
|
| 124 |
+
inside its rollout, so resharding after each one pays 30 all gathers per
|
| 125 |
+
forward to reclaim memory the (no_grad) rollout never needed back.
|
| 126 |
+
|
| 127 |
+
There is deliberately no root `fully_shard(model)`. The root's hook fires
|
| 128 |
+
on `model.forward`, and nothing here ever calls it. `block_forward` drives
|
| 129 |
+
the model from outside. A parameter caught only by the root group would
|
| 130 |
+
therefore stay sharded through every forward, silently. Everything is
|
| 131 |
+
sharded at a module that is genuinely entered instead, and the assertion at
|
| 132 |
+
the end refuses to return a model where that did not hold.
|
| 133 |
+
"""
|
| 134 |
+
from torch.distributed.fsdp import fully_shard
|
| 135 |
+
|
| 136 |
+
# An explicit, NAMED mesh. `fully_shard(mesh=None)` synthesises one per
|
| 137 |
+
# call with `mesh_dim_names=None`, and composing shards over children then
|
| 138 |
+
# dies in `_init_sharded_param` trying to concatenate those names.
|
| 139 |
+
if mesh is None:
|
| 140 |
+
from torch.distributed.device_mesh import init_device_mesh
|
| 141 |
+
import torch.distributed as dist
|
| 142 |
+
mesh = init_device_mesh('cuda', (dist.get_world_size(),),
|
| 143 |
+
mesh_dim_names=('dp',))
|
| 144 |
+
kw = {'mesh': mesh, 'reshard_after_forward': reshard_after_forward}
|
| 145 |
+
if mp_policy is not None:
|
| 146 |
+
kw['mp_policy'] = mp_policy
|
| 147 |
+
# `ignored_params` is how the LoRA critic survives sharding its own base.
|
| 148 |
+
# The frozen teacher is bf16 with no gradients; the adapter living inside
|
| 149 |
+
# the same blocks is fp32 and trainable, and it is 32 M parameters (small
|
| 150 |
+
# enough that replicating it and all reducing by hand is cheaper than
|
| 151 |
+
# sharding, and it keeps mixed dtypes out of a single FSDP parameter group.
|
| 152 |
+
if ignored_params:
|
| 153 |
+
kw['ignored_params'] = set(ignored_params)
|
| 154 |
+
|
| 155 |
+
attach_causal_modules(model)
|
| 156 |
+
units = list(model.causal_layers) + [model.causal_head]
|
| 157 |
+
# These four are invoked through their own __call__ already (patch_embedding
|
| 158 |
+
# in `_run`, text_embedding in the trainers, time_embedding and
|
| 159 |
+
# time_projection in `time_embed`), so they need no wrapper.
|
| 160 |
+
units += [model.patch_embedding, model.text_embedding,
|
| 161 |
+
model.time_embedding, model.time_projection]
|
| 162 |
+
for m in units:
|
| 163 |
+
fully_shard(m, **kw)
|
| 164 |
+
|
| 165 |
+
ignore = set(id(p) for p in (ignored_params or ()))
|
| 166 |
+
stragglers = [n for n, p in model.named_parameters()
|
| 167 |
+
if id(p) not in ignore
|
| 168 |
+
and not isinstance(p, torch.distributed.tensor.DTensor)]
|
| 169 |
+
if stragglers:
|
| 170 |
+
raise RuntimeError(
|
| 171 |
+
f'{len(stragglers)} parameter(s) were not sharded and are not '
|
| 172 |
+
f'reachable from any entered module -- they would train '
|
| 173 |
+
f'unsynchronised: {stragglers[:8]}')
|
| 174 |
+
return model
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def set_grad_sync(model, flag):
|
| 178 |
+
"""FSDP2's equivalent of `DDP.no_sync()`, over every shard unit.
|
| 179 |
+
|
| 180 |
+
With gradient accumulation, leaving this on costs one reduce-scatter per
|
| 181 |
+
micro-step instead of one per optimizer step. It is not a correctness issue
|
| 182 |
+
-- reducing each micro-step and summing equals summing then reducing but
|
| 183 |
+
at `--accum 4` it is four times the collectives for the same gradient.
|
| 184 |
+
"""
|
| 185 |
+
for m in model.modules():
|
| 186 |
+
if hasattr(m, 'set_requires_gradient_sync'):
|
| 187 |
+
m.set_requires_gradient_sync(flag)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def full_state_dict(model):
|
| 191 |
+
"""Unsharded fp32 state dict on CPU, in the same key layout the existing
|
| 192 |
+
checkpoints use, so `demo.py --weights` keeps working unchanged.
|
| 193 |
+
|
| 194 |
+
Under FSDP2 `model.state_dict()` returns DTensors -- saving those would
|
| 195 |
+
produce a checkpoint that only reloads onto an identical mesh, and every
|
| 196 |
+
evaluation script here loads onto one GPU.
|
| 197 |
+
"""
|
| 198 |
+
|
| 199 |
+
sd = get_model_state_dict(
|
| 200 |
+
model, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
|
| 201 |
+
# `attach_causal_modules` renamed the shard units, so the keys now read
|
| 202 |
+
# `causal_layers.7.blk.*` and `causal_head.head.*`. Put them back under the
|
| 203 |
+
# names WanModel.load_state_dict expects, so demo.py and every other
|
| 204 |
+
# evaluation script keep loading these checkpoints on a single GPU.
|
| 205 |
+
out = {}
|
| 206 |
+
for k, v in sd.items():
|
| 207 |
+
if k.startswith('causal_layers.'):
|
| 208 |
+
i, rest = k[len('causal_layers.'):].split('.blk.', 1)
|
| 209 |
+
k = f'blocks.{i}.{rest}'
|
| 210 |
+
elif k.startswith('causal_head.head.'):
|
| 211 |
+
k = 'head.' + k[len('causal_head.head.'):]
|
| 212 |
+
out[k] = v
|
| 213 |
+
return out
|
wanstreamer/graphrunner.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CUDA-graph capture for the streaming forward.
|
| 2 |
+
|
| 3 |
+
Why this and not a smaller optimisation: the prior profiling of this pipeline
|
| 4 |
+
found ~7,300 kernel launches and only ~17% arithmetic in a single frame-forward,
|
| 5 |
+
and the sweep here reproduces the signature exactly -- a 1-latent-frame unit and
|
| 6 |
+
a 3-latent-frame unit cost nearly the same per forward (119 ms vs 168 ms) even
|
| 7 |
+
though the second does 3x the work. The bottleneck is launch overhead, so the
|
| 8 |
+
fix has to remove launches, not work. A CUDA graph replays the whole forward as
|
| 9 |
+
one submission.
|
| 10 |
+
|
| 11 |
+
The capture is only valid because streaming reaches a genuine steady state:
|
| 12 |
+
|
| 13 |
+
* the event K/V window is bounded, so once it is full `cache.length` stops
|
| 14 |
+
changing and every shape in the forward is constant;
|
| 15 |
+
* the cache is one preallocated buffer written in place at a constant offset,
|
| 16 |
+
so the graph can own those writes;
|
| 17 |
+
* only three things vary between calls -- the latents, the timestep, and the
|
| 18 |
+
RoPE table slice for the current absolute frame index -- and all three are
|
| 19 |
+
copied into static input buffers before replay.
|
| 20 |
+
|
| 21 |
+
So: run eagerly until the window fills, capture once, then replay. Anything that
|
| 22 |
+
would change a shape (a different block size, the window not yet full) falls
|
| 23 |
+
back to eager automatically.
|
| 24 |
+
"""
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class GraphedForward:
|
| 29 |
+
"""Captures `fn(z, tbl, e, e0) -> v` for one fixed streaming configuration."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, fn, z_shape, tbl_shape, e_shape, e0_shape, device,
|
| 32 |
+
dtype=torch.float32, warmup=3):
|
| 33 |
+
self.fn = fn
|
| 34 |
+
self.device = device
|
| 35 |
+
self.z = torch.zeros(z_shape, device=device, dtype=dtype)
|
| 36 |
+
self.tbl = torch.zeros(tbl_shape, device=device, dtype=torch.complex64)
|
| 37 |
+
self.e = torch.zeros(e_shape, device=device, dtype=torch.float32)
|
| 38 |
+
self.e0 = torch.zeros(e0_shape, device=device, dtype=torch.float32)
|
| 39 |
+
self.graph = None
|
| 40 |
+
self.warmup = warmup
|
| 41 |
+
self.out = None
|
| 42 |
+
self.key = None
|
| 43 |
+
|
| 44 |
+
def capture(self):
|
| 45 |
+
# Warm up on a side stream first: cuDNN/cuBLAS pick algorithms and
|
| 46 |
+
# allocate workspaces on first call, and that must not happen during
|
| 47 |
+
# capture.
|
| 48 |
+
s = torch.cuda.Stream()
|
| 49 |
+
s.wait_stream(torch.cuda.current_stream())
|
| 50 |
+
with torch.cuda.stream(s):
|
| 51 |
+
for _ in range(self.warmup):
|
| 52 |
+
out = self.fn(self.z, self.tbl, self.e, self.e0)
|
| 53 |
+
torch.cuda.current_stream().wait_stream(s)
|
| 54 |
+
torch.cuda.synchronize()
|
| 55 |
+
|
| 56 |
+
self.graph = torch.cuda.CUDAGraph()
|
| 57 |
+
with torch.cuda.graph(self.graph):
|
| 58 |
+
self.out = self.fn(self.z, self.tbl, self.e, self.e0)
|
| 59 |
+
return self
|
| 60 |
+
|
| 61 |
+
def __call__(self, z, tbl, e, e0):
|
| 62 |
+
self.z.copy_(z)
|
| 63 |
+
self.tbl.copy_(tbl)
|
| 64 |
+
self.e.copy_(e)
|
| 65 |
+
self.e0.copy_(e0)
|
| 66 |
+
self.graph.replay()
|
| 67 |
+
return self.out
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class SteadyStateGraphs:
|
| 71 |
+
"""One graph per distinct (cache length, block size) the stream settles into.
|
| 72 |
+
|
| 73 |
+
A stream with a bounded window visits at most a handful of cache lengths
|
| 74 |
+
before it saturates, so keying on the length is enough; the dict never grows
|
| 75 |
+
without bound. `enabled=False` makes every call fall through to eager, which
|
| 76 |
+
is what the correctness comparison in scripts/bench_graph.py uses.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
def __init__(self, enabled=True, max_graphs=4):
|
| 80 |
+
self.enabled = enabled
|
| 81 |
+
self.graphs = {}
|
| 82 |
+
self.max_graphs = max_graphs
|
| 83 |
+
self.captures = 0
|
| 84 |
+
self.replays = 0
|
| 85 |
+
|
| 86 |
+
def get(self, key, make):
|
| 87 |
+
if not self.enabled:
|
| 88 |
+
return None
|
| 89 |
+
g = self.graphs.get(key)
|
| 90 |
+
if g is None:
|
| 91 |
+
if len(self.graphs) >= self.max_graphs:
|
| 92 |
+
return None
|
| 93 |
+
g = make().capture()
|
| 94 |
+
self.graphs[key] = g
|
| 95 |
+
self.captures += 1
|
| 96 |
+
self.replays += 1
|
| 97 |
+
return g
|
wanstreamer/kvcache.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Preallocated K/V cache for block-causal streaming.
|
| 2 |
+
|
| 3 |
+
Replaces streaming_blocks.KVFrameCache, which stored a deque of per-frame tensors
|
| 4 |
+
and rebuilt the context with `torch.cat` on every `get()`. The profile in
|
| 5 |
+
PROGRESS.md §6 counted 302 `aten::cat` calls per single frame-forward.
|
| 6 |
+
|
| 7 |
+
Here the cache is one preallocated buffer per layer and the context is a *view*,
|
| 8 |
+
so steady-state streaming does no allocation and no copying beyond writing the
|
| 9 |
+
new frame's K/V.
|
| 10 |
+
|
| 11 |
+
Commit semantics matter for multi-step denoising. The current chunk's K/V changes
|
| 12 |
+
at every denoising step, so it must not be committed until the chunk is final:
|
| 13 |
+
|
| 14 |
+
for step in steps: # chunk still noisy
|
| 15 |
+
cache.write(layer, k, v) # scratch region at [len, len+S)
|
| 16 |
+
ctx_k, ctx_v = cache.context(layer, S) # past + current, one view
|
| 17 |
+
cache.commit(S) # chunk finalised, becomes past
|
| 18 |
+
|
| 19 |
+
Because the cache only ever holds past + current frames, full attention over that
|
| 20 |
+
view IS block-causal -- bidirectional within the current chunk, unrestricted over
|
| 21 |
+
the past, and no future keys exist to leak. No mask is needed; see
|
| 22 |
+
tests/test_attention_fallback.py case 5.
|
| 23 |
+
"""
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class StreamingKVCache:
|
| 28 |
+
"""Per-layer ring-free preallocated K/V store with an uncommitted scratch tail."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, num_layers, max_tokens, num_heads, head_dim,
|
| 31 |
+
device, dtype=torch.bfloat16, scratch_tokens=None):
|
| 32 |
+
self.num_layers = num_layers
|
| 33 |
+
self.max_tokens = max_tokens
|
| 34 |
+
self.scratch = scratch_tokens if scratch_tokens is not None else max_tokens
|
| 35 |
+
cap = max_tokens + self.scratch
|
| 36 |
+
self.k = torch.zeros(num_layers, cap, num_heads, head_dim,
|
| 37 |
+
device=device, dtype=dtype)
|
| 38 |
+
self.v = torch.zeros(num_layers, cap, num_heads, head_dim,
|
| 39 |
+
device=device, dtype=dtype)
|
| 40 |
+
self.length = 0 # committed tokens
|
| 41 |
+
self._pending = 0 # tokens written to scratch, not yet committed
|
| 42 |
+
# Monotone count of eviction events. `BufferPrefixKV` reconstructs a
|
| 43 |
+
# recorded step's prefix as `buffer[:upto]`, which is only that step's
|
| 44 |
+
# actual prefix if nothing has been evicted since it was recorded --
|
| 45 |
+
# eviction shifts surviving keys down and discards the oldest outright.
|
| 46 |
+
# The self-forcing trainer compares this counter against the value it
|
| 47 |
+
# stamped on each recorded block and refuses to take the gradient on a
|
| 48 |
+
# block whose prefix has since moved. See stream.trim_to_window.
|
| 49 |
+
self.evictions = 0
|
| 50 |
+
|
| 51 |
+
def reset(self):
|
| 52 |
+
self.length = 0
|
| 53 |
+
self._pending = 0
|
| 54 |
+
self.evictions = 0
|
| 55 |
+
|
| 56 |
+
def write(self, layer, k, v):
|
| 57 |
+
"""Write the current (uncommitted) chunk's K/V at [length, length+S)."""
|
| 58 |
+
s = k.shape[1]
|
| 59 |
+
if self.length + s > self.k.shape[1]:
|
| 60 |
+
raise RuntimeError(
|
| 61 |
+
f'K/V cache overflow: {self.length} committed + {s} pending > '
|
| 62 |
+
f'capacity {self.k.shape[1]}. Raise max_tokens or evict.')
|
| 63 |
+
self.k[layer, self.length:self.length + s].copy_(k[0])
|
| 64 |
+
self.v[layer, self.length:self.length + s].copy_(v[0])
|
| 65 |
+
self._pending = s
|
| 66 |
+
|
| 67 |
+
def context(self, layer, s):
|
| 68 |
+
"""View of past + current: ([1, length+s, n, d], same for v). No copy."""
|
| 69 |
+
end = self.length + s
|
| 70 |
+
return (self.k[layer, :end].unsqueeze(0),
|
| 71 |
+
self.v[layer, :end].unsqueeze(0))
|
| 72 |
+
|
| 73 |
+
def commit(self, s):
|
| 74 |
+
"""Promote the pending chunk to committed history."""
|
| 75 |
+
self.length += s
|
| 76 |
+
self._pending = 0
|
| 77 |
+
|
| 78 |
+
def evict_front(self, s, protect=0):
|
| 79 |
+
"""Drop `s` tokens from the front of the evictable region.
|
| 80 |
+
|
| 81 |
+
`protect` pins the first `protect` tokens permanently -- used to keep the
|
| 82 |
+
WORLD block resident while sliding a window over the event stream, which
|
| 83 |
+
is the behaviour the papers describe (W is persistent, events stream).
|
| 84 |
+
Eviction therefore removes the OLDEST EVENTS, not the world.
|
| 85 |
+
"""
|
| 86 |
+
if s <= 0:
|
| 87 |
+
return
|
| 88 |
+
evictable = self.length - protect
|
| 89 |
+
s = min(s, max(0, evictable))
|
| 90 |
+
if s == 0:
|
| 91 |
+
return
|
| 92 |
+
keep = self.length - protect - s # events surviving the eviction
|
| 93 |
+
if keep > 0:
|
| 94 |
+
src = protect + s
|
| 95 |
+
self.k[:, protect:protect + keep].copy_(self.k[:, src:src + keep])
|
| 96 |
+
self.v[:, protect:protect + keep].copy_(self.v[:, src:src + keep])
|
| 97 |
+
self.length = protect + keep
|
| 98 |
+
self.evictions += 1
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def num_tokens(self):
|
| 102 |
+
return self.length
|
| 103 |
+
|
| 104 |
+
def memory_bytes(self):
|
| 105 |
+
return self.k.numel() * self.k.element_size() * 2
|
wanstreamer/lora.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal LoRA, used to make the DMD critic share the teacher's weights.
|
| 2 |
+
|
| 3 |
+
DMD needs three networks: the causal student, a frozen *real* score (the
|
| 4 |
+
original bidirectional Wan) and a trainable *fake* score that tracks the
|
| 5 |
+
student's own output distribution. Three full 1.4B copies plus the student's
|
| 6 |
+
AdamW state does not fit on a 40 GB card, and the fake score's correct
|
| 7 |
+
initialisation is exactly the real score anyway -- so the fake score is the same
|
| 8 |
+
frozen base with a low-rank adapter on top, toggled by a flag:
|
| 9 |
+
|
| 10 |
+
with lora_enabled(base, False): v_real = ... # teacher
|
| 11 |
+
with lora_enabled(base, True): v_fake = ... # critic
|
| 12 |
+
|
| 13 |
+
That costs ~32 M trainable parameters instead of 1.4 B, starts the critic at the
|
| 14 |
+
right place by construction, and leaves the base weights bit-identical between
|
| 15 |
+
the two roles.
|
| 16 |
+
"""
|
| 17 |
+
from contextlib import contextmanager
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LoRALinear(nn.Module):
|
| 24 |
+
def __init__(self, base: nn.Linear, rank=32, alpha=None):
|
| 25 |
+
super().__init__()
|
| 26 |
+
self.base = base
|
| 27 |
+
self.base.weight.requires_grad_(False)
|
| 28 |
+
if self.base.bias is not None:
|
| 29 |
+
self.base.bias.requires_grad_(False)
|
| 30 |
+
self.rank = rank
|
| 31 |
+
self.scale = (alpha or rank) / rank
|
| 32 |
+
self.a = nn.Parameter(torch.zeros(rank, base.in_features))
|
| 33 |
+
self.b = nn.Parameter(torch.zeros(base.out_features, rank))
|
| 34 |
+
nn.init.normal_(self.a, std=1.0 / rank) # b stays zero -> starts as identity
|
| 35 |
+
self.enabled = True
|
| 36 |
+
|
| 37 |
+
def forward(self, x):
|
| 38 |
+
y = self.base(x)
|
| 39 |
+
if not self.enabled:
|
| 40 |
+
return y
|
| 41 |
+
h = nn.functional.linear(x.to(self.a.dtype), self.a)
|
| 42 |
+
return y + nn.functional.linear(h, self.b).to(y.dtype) * self.scale
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
TARGETS = ('self_attn.q', 'self_attn.k', 'self_attn.v', 'self_attn.o',
|
| 46 |
+
'ffn.0', 'ffn.2')
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def inject_lora(model, rank=32, alpha=None, targets=TARGETS):
|
| 50 |
+
"""Wrap the targeted Linears of every transformer block. Returns the new
|
| 51 |
+
parameters, and freezes everything else in the model."""
|
| 52 |
+
for p in model.parameters():
|
| 53 |
+
p.requires_grad_(False)
|
| 54 |
+
n = 0
|
| 55 |
+
for blk in model.blocks:
|
| 56 |
+
for name in targets:
|
| 57 |
+
parent, _, leaf = name.rpartition('.')
|
| 58 |
+
mod = blk.get_submodule(parent) if parent else blk
|
| 59 |
+
lin = getattr(mod, leaf) if not leaf.isdigit() else mod[int(leaf)]
|
| 60 |
+
if isinstance(lin, LoRALinear):
|
| 61 |
+
continue
|
| 62 |
+
wrapped = LoRALinear(lin, rank, alpha).to(lin.weight.device)
|
| 63 |
+
wrapped.a.data = wrapped.a.data.float()
|
| 64 |
+
wrapped.b.data = wrapped.b.data.float()
|
| 65 |
+
if leaf.isdigit():
|
| 66 |
+
mod[int(leaf)] = wrapped
|
| 67 |
+
else:
|
| 68 |
+
setattr(mod, leaf, wrapped)
|
| 69 |
+
n += 1
|
| 70 |
+
params = [p for p in model.parameters() if p.requires_grad]
|
| 71 |
+
total = sum(p.numel() for p in params)
|
| 72 |
+
return params, n, total
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def set_lora(model, on):
|
| 76 |
+
for m in model.modules():
|
| 77 |
+
if isinstance(m, LoRALinear):
|
| 78 |
+
m.enabled = on
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@contextmanager
|
| 82 |
+
def lora_enabled(model, on):
|
| 83 |
+
prev = [m.enabled for m in model.modules() if isinstance(m, LoRALinear)]
|
| 84 |
+
set_lora(model, on)
|
| 85 |
+
try:
|
| 86 |
+
yield
|
| 87 |
+
finally:
|
| 88 |
+
for m, p in zip((m for m in model.modules()
|
| 89 |
+
if isinstance(m, LoRALinear)), prev):
|
| 90 |
+
m.enabled = p
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def lora_state_dict(model):
|
| 94 |
+
return {k: v for k, v in model.state_dict().items()
|
| 95 |
+
if k.endswith('.a') or k.endswith('.b')}
|
wanstreamer/metrics.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Objective quality statistics for a streamed video.
|
| 2 |
+
|
| 3 |
+
There is no reference video to compare against -- the stream is a continuation
|
| 4 |
+
that never existed -- so these are no-reference statistics chosen to catch the
|
| 5 |
+
three failure modes actually observed in this project (PROGRESS.md §8):
|
| 6 |
+
|
| 7 |
+
collapse the model loses signal and output greys out.
|
| 8 |
+
-> latent std ratio vs the world, and pixel contrast.
|
| 9 |
+
blur output stays plausible but soft, losing high frequencies.
|
| 10 |
+
-> Laplacian variance (standard sharpness proxy), relative to the
|
| 11 |
+
world frames produced by the same VAE at the same resolution, so
|
| 12 |
+
the number is a ratio against a known-good reference.
|
| 13 |
+
freeze/flicker motion either stops or becomes incoherent.
|
| 14 |
+
-> mean |frame_t - frame_{t-1}|; a healthy talking-head sits well
|
| 15 |
+
above 0 and well below the world's own inter-frame delta * ~2.
|
| 16 |
+
|
| 17 |
+
Every statistic is reported for the WORLD segment and the GENERATED segment
|
| 18 |
+
separately, plus their ratio. Ratios near 1.0 mean the stream matches the quality
|
| 19 |
+
of the bidirectional teacher output it continues -- that is the target.
|
| 20 |
+
|
| 21 |
+
`drift` measures whether the stream wanders away from the established scene:
|
| 22 |
+
cosine similarity in latent space between each generated frame and the mean world
|
| 23 |
+
latent. A monotone decline is drift; a flat line is a stable identity.
|
| 24 |
+
"""
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _lap_var(gray):
|
| 29 |
+
"""Variance of the 3x3 Laplacian -- higher is sharper. gray: [H, W] float."""
|
| 30 |
+
lap = (-4.0 * gray[1:-1, 1:-1]
|
| 31 |
+
+ gray[:-2, 1:-1] + gray[2:, 1:-1]
|
| 32 |
+
+ gray[1:-1, :-2] + gray[1:-1, 2:])
|
| 33 |
+
return float(lap.var())
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _blockiness(gray, period):
|
| 37 |
+
"""Ratio of gradient energy ON a `period`-aligned grid to gradient energy off
|
| 38 |
+
it. ~1.0 means no grid structure; >1.2 is visible blocking.
|
| 39 |
+
|
| 40 |
+
This exists because Laplacian sharpness is gameable: the highest-sharpness arm
|
| 41 |
+
in out_sweep/round2 scored 0.38 while decoding to a blocky checkerboard, and a
|
| 42 |
+
periodic artifact grid raises high-frequency energy exactly like real detail
|
| 43 |
+
does. Wan's VAE has spatial stride 8 and the DiT patch is 2 latent cells, so
|
| 44 |
+
artifacts land on 8- and 16-pixel grids; measuring those periods separates
|
| 45 |
+
structure from detail.
|
| 46 |
+
"""
|
| 47 |
+
dv = np.abs(np.diff(gray, axis=1)) # [H, W-1], boundary between x,x+1
|
| 48 |
+
dh = np.abs(np.diff(gray, axis=0))
|
| 49 |
+
out = []
|
| 50 |
+
for d, axis in ((dv, 1), (dh, 0)):
|
| 51 |
+
n = d.shape[axis]
|
| 52 |
+
on_idx = np.arange(period - 1, n, period)
|
| 53 |
+
if len(on_idx) < 2:
|
| 54 |
+
continue
|
| 55 |
+
mask = np.zeros(n, dtype=bool)
|
| 56 |
+
mask[on_idx] = True
|
| 57 |
+
on = d[:, mask] if axis == 1 else d[mask, :]
|
| 58 |
+
off = d[:, ~mask] if axis == 1 else d[~mask, :]
|
| 59 |
+
if off.size and off.mean() > 1e-8:
|
| 60 |
+
out.append(float(on.mean() / off.mean()))
|
| 61 |
+
return max(out) if out else float('nan')
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def video_stats(pix, split):
|
| 65 |
+
"""pix: uint8 [N, H, W, 3] numpy. split: index where generated frames begin."""
|
| 66 |
+
g = pix.astype(np.float32).mean(axis=3) / 255.0 # [N, H, W] luma
|
| 67 |
+
sharp = np.array([_lap_var(g[i]) for i in range(g.shape[0])])
|
| 68 |
+
contrast = g.reshape(g.shape[0], -1).std(axis=1)
|
| 69 |
+
delta = np.abs(np.diff(g, axis=0)).mean(axis=(1, 2)) # length N-1
|
| 70 |
+
|
| 71 |
+
def seg(a, lo, hi):
|
| 72 |
+
s = a[lo:hi]
|
| 73 |
+
return float(s.mean()) if len(s) else float('nan')
|
| 74 |
+
|
| 75 |
+
n = g.shape[0]
|
| 76 |
+
out = {
|
| 77 |
+
'sharpness_world': seg(sharp, 0, split),
|
| 78 |
+
'sharpness_gen': seg(sharp, split, n),
|
| 79 |
+
'contrast_world': seg(contrast, 0, split),
|
| 80 |
+
'contrast_gen': seg(contrast, split, n),
|
| 81 |
+
# deltas are between frames i and i+1, so the generated segment's own
|
| 82 |
+
# deltas start at `split` (the seam frame is excluded as it spans both)
|
| 83 |
+
'interframe_world': seg(delta, 0, max(0, split - 1)),
|
| 84 |
+
'interframe_gen': seg(delta, split, n - 1),
|
| 85 |
+
}
|
| 86 |
+
# blockiness on the last 8 generated frames (artifacts accumulate, so the tail
|
| 87 |
+
# is where they show) vs the world's own baseline from the same VAE
|
| 88 |
+
for per in (8, 16):
|
| 89 |
+
wb = np.mean([_blockiness(g[i], per) for i in range(min(split, 8))])
|
| 90 |
+
gb = np.mean([_blockiness(g[i], per) for i in range(max(split, n - 8), n)])
|
| 91 |
+
out[f'block{per}_world'] = float(wb)
|
| 92 |
+
out[f'block{per}_gen'] = float(gb)
|
| 93 |
+
out['blockiness'] = float(max(out['block8_gen'] / max(out['block8_world'], 1e-6),
|
| 94 |
+
out['block16_gen'] / max(out['block16_world'], 1e-6)))
|
| 95 |
+
|
| 96 |
+
for key in ('sharpness', 'contrast', 'interframe'):
|
| 97 |
+
w, gg = out[f'{key}_world'], out[f'{key}_gen']
|
| 98 |
+
out[f'{key}_ratio'] = float(gg / w) if w else float('nan')
|
| 99 |
+
# trend over the generated segment: first vs last third, to expose decay
|
| 100 |
+
gs = sharp[split:]
|
| 101 |
+
if len(gs) >= 6:
|
| 102 |
+
third = len(gs) // 3
|
| 103 |
+
out['sharpness_first_third'] = float(gs[:third].mean())
|
| 104 |
+
out['sharpness_last_third'] = float(gs[-third:].mean())
|
| 105 |
+
out['sharpness_decay'] = float(gs[-third:].mean() / gs[:third].mean())
|
| 106 |
+
return out
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def channel_drift(world_lat, gen_lat, tail_frac=0.34):
|
| 110 |
+
"""Per-channel first/second-moment departure from the world, in latent space.
|
| 111 |
+
|
| 112 |
+
Every no-reference statistic in this file, and the reference-based Frechet
|
| 113 |
+
one in scripts/fwd_score.py, failed to rank the colour/saturation artifact
|
| 114 |
+
(FINDINGS.md §5a: the Frechet metric put the most saturated run first and
|
| 115 |
+
the only clean run fifth). They fail for the same reason -- each pools away
|
| 116 |
+
the axis the artifact actually lives on.
|
| 117 |
+
|
| 118 |
+
That axis is not hidden. `FewStepStreamer._renorm`, the inference patch that
|
| 119 |
+
hides the artifact, works by matching the generated block's PER-CHANNEL mean
|
| 120 |
+
and std to the world's. So the artifact is, by construction, a per-channel
|
| 121 |
+
moment departure, and measuring it needs no network at all:
|
| 122 |
+
|
| 123 |
+
mu_shift[c] = |mean(gen[c]) - mean(world[c])| / std(world[c])
|
| 124 |
+
sd_ratio[c] = std(gen[c]) / std(world[c])
|
| 125 |
+
|
| 126 |
+
Reported as the WORST channel, not the mean: a cast in one or two of the 16
|
| 127 |
+
latent channels is exactly what a magenta face is, and averaging over
|
| 128 |
+
channels would dilute it back into invisibility. Measured over the tail of
|
| 129 |
+
the rollout, where drift has accumulated.
|
| 130 |
+
|
| 131 |
+
Note this is only meaningful with the latent-norm patch OFF; with it on it
|
| 132 |
+
is being directly optimised, and reads ~0 by construction.
|
| 133 |
+
"""
|
| 134 |
+
w = world_lat.float()
|
| 135 |
+
g = gen_lat.float()
|
| 136 |
+
n_tail = max(1, int(g.shape[1] * tail_frac))
|
| 137 |
+
t = g[:, -n_tail:]
|
| 138 |
+
wm = w.mean(dim=(1, 2, 3))
|
| 139 |
+
ws = w.std(dim=(1, 2, 3)).clamp_min(1e-6)
|
| 140 |
+
mu_shift = ((t.mean(dim=(1, 2, 3)) - wm).abs() / ws)
|
| 141 |
+
sd_ratio = (t.std(dim=(1, 2, 3)) / ws)
|
| 142 |
+
return {
|
| 143 |
+
'chan_mu_shift_worst': float(mu_shift.max()),
|
| 144 |
+
'chan_mu_shift_mean': float(mu_shift.mean()),
|
| 145 |
+
'chan_sd_ratio_worst': float(sd_ratio.max()),
|
| 146 |
+
'chan_sd_ratio_min': float(sd_ratio.min()),
|
| 147 |
+
'chan_worst_index': int(mu_shift.argmax()),
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def latent_stats(world_lat, gen_lat):
|
| 152 |
+
"""world_lat/gen_lat: torch [C, F, H, W] float. Adds drift + collapse terms."""
|
| 153 |
+
w = world_lat.float()
|
| 154 |
+
g = gen_lat.float()
|
| 155 |
+
ref = w.mean(dim=1, keepdim=True).flatten()
|
| 156 |
+
ref = ref / (ref.norm() + 1e-8)
|
| 157 |
+
cos = []
|
| 158 |
+
for i in range(g.shape[1]):
|
| 159 |
+
f = g[:, i].flatten()
|
| 160 |
+
cos.append(float((f / (f.norm() + 1e-8) @ ref)))
|
| 161 |
+
per_frame_std = [float(g[:, i].std()) for i in range(g.shape[1])]
|
| 162 |
+
third = max(1, len(cos) // 3)
|
| 163 |
+
return {
|
| 164 |
+
'std_world': float(w.std()),
|
| 165 |
+
'std_gen': float(g.std()),
|
| 166 |
+
'std_ratio': float(g.std() / w.std()),
|
| 167 |
+
'std_first_frame': per_frame_std[0],
|
| 168 |
+
'std_last_frame': per_frame_std[-1],
|
| 169 |
+
'std_frame_decay': float(per_frame_std[-1] / per_frame_std[0]),
|
| 170 |
+
'world_cos_first_third': float(np.mean(cos[:third])),
|
| 171 |
+
'world_cos_last_third': float(np.mean(cos[-third:])),
|
| 172 |
+
'world_cos_drift': float(np.mean(cos[-third:]) - np.mean(cos[:third])),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def summary_line(vs, ls):
|
| 177 |
+
return (f"sharp {vs['sharpness_ratio']:.2f}x "
|
| 178 |
+
f"contrast {vs['contrast_ratio']:.2f}x "
|
| 179 |
+
f"motion {vs['interframe_ratio']:.2f}x "
|
| 180 |
+
f"blockiness {vs['blockiness']:.2f}x "
|
| 181 |
+
f"latent-std {ls['std_ratio']:.2f}x "
|
| 182 |
+
f"sharp-decay {vs.get('sharpness_decay', float('nan')):.2f} "
|
| 183 |
+
f"world-cos-drift {ls['world_cos_drift']:+.3f}")
|
wanstreamer/pipeline.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming generator: persistent world W + autoregressive event stream.
|
| 2 |
+
|
| 3 |
+
Implements the modelling contract the two papers share, at 1.3B scale:
|
| 4 |
+
|
| 5 |
+
p(e_1..K | W, x_1..K) = prod_k p(e_k | W, x_<=k, e_<k)
|
| 6 |
+
|
| 7 |
+
* WORLD W -- the established scene/character. Primed ONCE as a single
|
| 8 |
+
bidirectional block, then its K/V is frozen in the cache (v0.2's "KV
|
| 9 |
+
construction").
|
| 10 |
+
* EVENT STREAM -- emitted one chunk at a time. A chunk attends bidirectionally
|
| 11 |
+
within itself and freely over the whole world plus all prior events, and to
|
| 12 |
+
nothing else.
|
| 13 |
+
|
| 14 |
+
BLOCK-CAUSALITY IS STRUCTURAL. The cache only ever holds world + committed
|
| 15 |
+
events, so full attention over [cache, chunk] already gives exactly "world
|
| 16 |
+
bidirectional, events causal". No attention mask is required -- verified in
|
| 17 |
+
tests/test_attention_fallback.py case 5, and a mask would wrongly serialise
|
| 18 |
+
tokens *within* a chunk.
|
| 19 |
+
|
| 20 |
+
CHUNK SIZE. final.pt was fine-tuned with --num-frames 4, so it only ever saw
|
| 21 |
+
4-latent-frame clips. Generating one latent frame in isolation is out of
|
| 22 |
+
distribution; `chunk_frames=4` matches training. One latent frame = 4 pixel
|
| 23 |
+
frames = 160 ms at 25 FPS (Wan VAE temporal stride 4), so a 4-frame chunk covers
|
| 24 |
+
640 ms of video and must be produced in under 640 ms to sustain 25 FPS.
|
| 25 |
+
|
| 26 |
+
Timesteps are fed to the model in the [0,1] convention final.pt was fine-tuned on
|
| 27 |
+
(train_streaming.py:152 draws torch.rand()); see PROGRESS.md §5.
|
| 28 |
+
"""
|
| 29 |
+
import time
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
|
| 33 |
+
from wan.modules.model import sinusoidal_embedding_1d
|
| 34 |
+
from wan.modules.attention import flash_attention
|
| 35 |
+
from wan.utils.fm_solvers import (FlowDPMSolverMultistepScheduler,
|
| 36 |
+
get_sampling_sigmas, retrieve_timesteps)
|
| 37 |
+
|
| 38 |
+
from .core import (ModulationCache, make_rope_table, make_cache, latent_geometry,
|
| 39 |
+
timestep_to_train_scale)
|
| 40 |
+
from .rope import apply_rope
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class StreamingGenerator:
|
| 44 |
+
def __init__(self, model, ctx_emb, width=640, height=368, max_frames=64,
|
| 45 |
+
device='cuda', dtype=torch.bfloat16, window_frames=None,
|
| 46 |
+
time_scale=1.0, cache_frames=None):
|
| 47 |
+
"""window_frames: if set, evict oldest frames so the cache holds at most
|
| 48 |
+
this many (bounded memory + bounded per-chunk cost). None = unbounded.
|
| 49 |
+
|
| 50 |
+
time_scale converts the internal flow fraction t in [0,1] into the value
|
| 51 |
+
the WEIGHTS expect at their time embedding:
|
| 52 |
+
|
| 53 |
+
final.pt -> 1.0 (fine-tuned on torch.rand(), i.e. [0,1])
|
| 54 |
+
original Wan -> 1000.0 (trained on scheduler timesteps [0,1000])
|
| 55 |
+
|
| 56 |
+
This must match the checkpoint. Getting it backwards is not a small
|
| 57 |
+
degradation, it is total: measured normalised velocity error 0.064 vs
|
| 58 |
+
0.732 for the original weights under the two conventions (PROGRESS.md §5).
|
| 59 |
+
Everything internal to this class speaks the [0,1] fraction.
|
| 60 |
+
"""
|
| 61 |
+
self.model = model
|
| 62 |
+
self.device = device
|
| 63 |
+
self.dtype = dtype
|
| 64 |
+
self.time_scale = float(time_scale)
|
| 65 |
+
self.h_lat, self.w_lat, self.hp, self.wp = latent_geometry(width, height)
|
| 66 |
+
self.tokens_per_frame = self.hp * self.wp
|
| 67 |
+
self.max_frames = max_frames
|
| 68 |
+
self.window_frames = window_frames
|
| 69 |
+
# RoPE must span the whole stream (absolute temporal indices keep growing),
|
| 70 |
+
# but the K/V cache only ever holds world + window, so sizing it by
|
| 71 |
+
# max_frames wastes memory quadratically in stream length: 30 layers x
|
| 72 |
+
# 6 KB/token means an unbounded 173 frame buffer at 1560 tok/frame is
|
| 73 |
+
# ~48 GiB, versus ~6 GiB for a 21 frame working set.
|
| 74 |
+
self.rope = make_rope_table(model, self.hp, self.wp, max_frames, device)
|
| 75 |
+
self.cache = make_cache(model, self.tokens_per_frame,
|
| 76 |
+
cache_frames or max_frames, device, dtype)
|
| 77 |
+
self.ctx = ctx_emb
|
| 78 |
+
self.ctx_lens = torch.tensor([ctx_emb.shape[1]], device=device, dtype=torch.long)
|
| 79 |
+
self.n_world = 0
|
| 80 |
+
self.n_frames = 0 # total committed latent frames
|
| 81 |
+
self.ref_stats = None # per-channel (mean, std) of the world latents
|
| 82 |
+
|
| 83 |
+
def _time_embed(self, t_frac):
|
| 84 |
+
"""t_frac is the flow fraction in [0,1]; time_scale maps it to the
|
| 85 |
+
checkpoint's own convention."""
|
| 86 |
+
tv = (torch.ones(1, device=self.device, dtype=torch.float32)
|
| 87 |
+
* float(t_frac) * self.time_scale)
|
| 88 |
+
with torch.amp.autocast('cuda', enabled=False):
|
| 89 |
+
e = self.model.time_embedding(
|
| 90 |
+
sinusoidal_embedding_1d(self.model.freq_dim, tv).float())
|
| 91 |
+
e0 = self.model.time_projection(e).unflatten(1, (6, self.model.dim))
|
| 92 |
+
return e, e0
|
| 93 |
+
|
| 94 |
+
@torch.no_grad()
|
| 95 |
+
def chunk_forward(self, z, t_frac, t_start, use_cache=True, ctx=None):
|
| 96 |
+
"""Run a chunk of latent frames against the cached past.
|
| 97 |
+
|
| 98 |
+
z: [1, C, N, H_lat, W_lat]. t_frac is the flow fraction in [0,1].
|
| 99 |
+
Writes the chunk's K/V into the cache scratch region (uncommitted).
|
| 100 |
+
Returns predicted velocity [C, N, H_lat, W_lat].
|
| 101 |
+
Attention is unmasked over [cache, chunk] == block-causal.
|
| 102 |
+
"""
|
| 103 |
+
model = self.model
|
| 104 |
+
ctx = self.ctx if ctx is None else ctx
|
| 105 |
+
N = z.shape[2]
|
| 106 |
+
S = N * self.tokens_per_frame
|
| 107 |
+
e, e0 = self._time_embed(t_frac)
|
| 108 |
+
mod = ModulationCache(model.blocks, e0)
|
| 109 |
+
|
| 110 |
+
with torch.amp.autocast('cuda', dtype=self.dtype):
|
| 111 |
+
x = model.patch_embedding(z.to(self.dtype))
|
| 112 |
+
grid = torch.stack([torch.tensor(x.shape[2:], dtype=torch.long,
|
| 113 |
+
device=self.device)])
|
| 114 |
+
x = x.flatten(2).transpose(1, 2)
|
| 115 |
+
tbl = self.rope.span(t_start, N)
|
| 116 |
+
|
| 117 |
+
for li, blk in enumerate(model.blocks):
|
| 118 |
+
ec = mod[li]
|
| 119 |
+
sa_in = blk.norm1(x).float() * (1 + ec[1]) + ec[0]
|
| 120 |
+
n = blk.num_heads
|
| 121 |
+
d = blk.dim // n
|
| 122 |
+
sa = blk.self_attn
|
| 123 |
+
q = sa.norm_q(sa.q(sa_in)).view(1, S, n, d)
|
| 124 |
+
k = sa.norm_k(sa.k(sa_in)).view(1, S, n, d)
|
| 125 |
+
v = sa.v(sa_in).view(1, S, n, d)
|
| 126 |
+
q = apply_rope(q, tbl).to(self.dtype)
|
| 127 |
+
k = apply_rope(k, tbl).to(self.dtype)
|
| 128 |
+
v = v.to(self.dtype)
|
| 129 |
+
|
| 130 |
+
if use_cache:
|
| 131 |
+
self.cache.write(li, k, v)
|
| 132 |
+
ck, cv = self.cache.context(li, S)
|
| 133 |
+
else:
|
| 134 |
+
ck, cv = k, v
|
| 135 |
+
y = flash_attention(q=q, k=ck, v=cv, window_size=(-1, -1),
|
| 136 |
+
causal=False)
|
| 137 |
+
y = sa.o(y.flatten(2))
|
| 138 |
+
with torch.amp.autocast('cuda', dtype=torch.float32):
|
| 139 |
+
x = x + y * ec[2]
|
| 140 |
+
x = x + blk.cross_attn(blk.norm3(x), ctx, self.ctx_lens)
|
| 141 |
+
yf = blk.ffn(blk.norm2(x).float() * (1 + ec[4]) + ec[3])
|
| 142 |
+
with torch.amp.autocast('cuda', dtype=torch.float32):
|
| 143 |
+
x = x + yf * ec[5]
|
| 144 |
+
|
| 145 |
+
return model.unpatchify(model.head(x, e), grid)[0]
|
| 146 |
+
|
| 147 |
+
def _commit(self, n_frames):
|
| 148 |
+
self.cache.commit(n_frames * self.tokens_per_frame)
|
| 149 |
+
self.n_frames += n_frames
|
| 150 |
+
if self.window_frames is not None:
|
| 151 |
+
# Persistent world + sliding event window: the world block is pinned,
|
| 152 |
+
# so eviction drops the OLDEST EVENTS. Bounds both memory and the
|
| 153 |
+
# per chunk attention cost, which is what makes throughput flat in
|
| 154 |
+
# stream length rather than degrading.
|
| 155 |
+
protect = self.n_world * self.tokens_per_frame
|
| 156 |
+
budget = (self.n_world + self.window_frames) * self.tokens_per_frame
|
| 157 |
+
excess = self.cache.num_tokens - budget
|
| 158 |
+
if excess > 0:
|
| 159 |
+
self.cache.evict_front(excess, protect=protect)
|
| 160 |
+
|
| 161 |
+
@torch.no_grad()
|
| 162 |
+
def set_world(self, world_latents, t_frac=0.0):
|
| 163 |
+
"""Prime the cache from clean world latents, attended bidirectionally."""
|
| 164 |
+
self.cache.reset()
|
| 165 |
+
self.n_frames = 0
|
| 166 |
+
z = world_latents.unsqueeze(0).to(self.device, self.dtype)
|
| 167 |
+
F = z.shape[2]
|
| 168 |
+
assert F <= self.max_frames
|
| 169 |
+
self.chunk_forward(z, t_frac, t_start=0, use_cache=True)
|
| 170 |
+
self._commit(F)
|
| 171 |
+
self.n_world = F
|
| 172 |
+
# Per channel moments of the world act as the in dist reference
|
| 173 |
+
# for latent_norm (see _renorm). Computed over (F, H, W) per channel.
|
| 174 |
+
w = world_latents.float()
|
| 175 |
+
self.ref_stats = (w.mean(dim=(1, 2, 3), keepdim=True).unsqueeze(0),
|
| 176 |
+
w.std(dim=(1, 2, 3), keepdim=True).unsqueeze(0))
|
| 177 |
+
return F
|
| 178 |
+
|
| 179 |
+
def _renorm(self, z, strength):
|
| 180 |
+
"""Pull a finished chunk's per-channel moments back to the world's.
|
| 181 |
+
|
| 182 |
+
Autoregressive rollout with a model that was never trained on its own
|
| 183 |
+
outputs accumulates error, and here that error is measurably a SCALE
|
| 184 |
+
divergence: with the original bidirectional weights, generated latent std
|
| 185 |
+
grows ~2x over 20 chunks and reaches 3x the world's, at which point the
|
| 186 |
+
VAE's input range is exceeded and frames decode to flat grey
|
| 187 |
+
(contrast 0.018 vs the world's 0.194 -- out_sweep/round1).
|
| 188 |
+
|
| 189 |
+
Matching the first two moments per channel is the cheapest correction that
|
| 190 |
+
targets exactly that failure, and it is applied BEFORE the chunk's K/V is
|
| 191 |
+
recomputed and committed, so the cached history stays in-distribution and
|
| 192 |
+
the correction cannot accumulate. strength blends: 0 = off, 1 = full
|
| 193 |
+
moment match. It cannot fix a wrong direction, only a wrong scale, so if
|
| 194 |
+
content quality is the problem this will not rescue it.
|
| 195 |
+
"""
|
| 196 |
+
if strength <= 0 or self.ref_stats is None:
|
| 197 |
+
return z
|
| 198 |
+
mu, sd = self.ref_stats
|
| 199 |
+
zm = z.mean(dim=(2, 3, 4), keepdim=True)
|
| 200 |
+
zs = z.std(dim=(2, 3, 4), keepdim=True)
|
| 201 |
+
z_n = (z - zm) / (zs + 1e-5) * sd.to(z.dtype) + mu.to(z.dtype)
|
| 202 |
+
return (1.0 - strength) * z + strength * z_n
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@torch.no_grad()
|
| 206 |
+
def generate_chunk(self, chunk_frames=4, num_steps=3, generator=None,
|
| 207 |
+
sampler='dpm', shift=5.0, guidance=None, ctx_neg=None,
|
| 208 |
+
rope_start=None, t_max=1.0, anchor=None, latent_norm=0.0):
|
| 209 |
+
"""Emit one event chunk autoregressively. Returns (latents, seconds).
|
| 210 |
+
|
| 211 |
+
t_max < 1.0 warm-starts the chunk instead of denoising from pure noise:
|
| 212 |
+
z_init = (1 - t_max) * anchor + t_max * noise
|
| 213 |
+
where `anchor` is the last clean latent frame, broadcast over the chunk.
|
| 214 |
+
This is SDEdit-style continuation. It is worth doing here because
|
| 215 |
+
diag/context_len_probe.py measured this model's velocity error at 0.33 for
|
| 216 |
+
t=0.8 versus 0.14 for t=0.5 -- denoising from t=1 integrates through the
|
| 217 |
+
region where it is least accurate. Trade-off: lower t_max means less
|
| 218 |
+
motion diversity, since the chunk starts closer to the previous frame.
|
| 219 |
+
"""
|
| 220 |
+
t_start = self.n_frames if rope_start is None else rope_start
|
| 221 |
+
if t_start + chunk_frames > self.max_frames:
|
| 222 |
+
raise RuntimeError(f'temporal index {t_start + chunk_frames} exceeds '
|
| 223 |
+
f'max_frames {self.max_frames}')
|
| 224 |
+
torch.cuda.synchronize()
|
| 225 |
+
t0 = time.perf_counter()
|
| 226 |
+
|
| 227 |
+
noise = torch.randn(1, 16, chunk_frames, self.h_lat, self.w_lat,
|
| 228 |
+
device=self.device, dtype=torch.float32,
|
| 229 |
+
generator=generator)
|
| 230 |
+
if t_max < 1.0:
|
| 231 |
+
if anchor is None:
|
| 232 |
+
raise ValueError('t_max < 1.0 requires an anchor latent frame')
|
| 233 |
+
a = anchor.to(self.device, torch.float32).unsqueeze(0)
|
| 234 |
+
if a.shape[2] != chunk_frames:
|
| 235 |
+
a = a[:, :, -1:].expand(-1, -1, chunk_frames, -1, -1)
|
| 236 |
+
z = (1 - t_max) * a + t_max * noise
|
| 237 |
+
else:
|
| 238 |
+
z = noise
|
| 239 |
+
|
| 240 |
+
def predict(z_in, t_frac):
|
| 241 |
+
v = self.chunk_forward(z_in, t_frac, t_start).float().unsqueeze(0)
|
| 242 |
+
if guidance and ctx_neg is not None:
|
| 243 |
+
vu = self.chunk_forward(z_in, t_frac, t_start,
|
| 244 |
+
ctx=ctx_neg).float().unsqueeze(0)
|
| 245 |
+
v = vu + guidance * (v - vu)
|
| 246 |
+
return v
|
| 247 |
+
|
| 248 |
+
if sampler == 'euler':
|
| 249 |
+
ts = torch.linspace(t_max, 0.0, num_steps + 1)
|
| 250 |
+
for i in range(num_steps):
|
| 251 |
+
tc, tn = float(ts[i]), float(ts[i + 1])
|
| 252 |
+
z = z + (tn - tc) * predict(z, tc)
|
| 253 |
+
elif sampler == 'dpm':
|
| 254 |
+
sch = FlowDPMSolverMultistepScheduler(
|
| 255 |
+
num_train_timesteps=1000, shift=1, use_dynamic_shifting=False)
|
| 256 |
+
sig = get_sampling_sigmas(num_steps, shift)
|
| 257 |
+
if t_max < 1.0: # rescale sigmas into [0, t_max]
|
| 258 |
+
sig = sig * t_max # keep it a numpy array
|
| 259 |
+
tsteps, _ = retrieve_timesteps(sch, device=self.device, sigmas=sig)
|
| 260 |
+
for tv in tsteps:
|
| 261 |
+
v = predict(z, timestep_to_train_scale(tv))
|
| 262 |
+
z = sch.step(v, tv, z, return_dict=False)[0]
|
| 263 |
+
else:
|
| 264 |
+
raise ValueError(sampler)
|
| 265 |
+
|
| 266 |
+
z = self._renorm(z, latent_norm)
|
| 267 |
+
|
| 268 |
+
# recompute the finished chunk's K/V at t=0 so history is clean, then commit
|
| 269 |
+
self.chunk_forward(z, 0.0, t_start)
|
| 270 |
+
self._commit(chunk_frames)
|
| 271 |
+
|
| 272 |
+
torch.cuda.synchronize()
|
| 273 |
+
return z[0], time.perf_counter() - t0
|
| 274 |
+
|
| 275 |
+
@torch.no_grad()
|
| 276 |
+
def stream(self, num_chunks, chunk_frames=4, num_steps=3, seed=0,
|
| 277 |
+
log_every=5, anchor=None, world_anchor=None, world_anchor_weight=0.0,
|
| 278 |
+
**kw):
|
| 279 |
+
"""world_anchor_weight w > 0 blends a fixed WORLD reference frame into the
|
| 280 |
+
warm start anchor: anchor = (1-w)·last_generated + w·world_ref.
|
| 281 |
+
|
| 282 |
+
Rationale: pinning the worlds K/V stabilises attention but the warm-start
|
| 283 |
+
anchor still forms a chain (each chunk starts from the previous chunks
|
| 284 |
+
output), so errors compound and long streams drift. v0.3 casts W as the
|
| 285 |
+
persistent carrier of scene and character identity, so letting W enter the
|
| 286 |
+
initialisation too is the faithful reading, not just a hack.
|
| 287 |
+
"""
|
| 288 |
+
g = torch.Generator(device=self.device).manual_seed(seed)
|
| 289 |
+
lats, times = [], []
|
| 290 |
+
for u in range(num_chunks):
|
| 291 |
+
lat, dt = self.generate_chunk(chunk_frames=chunk_frames,
|
| 292 |
+
num_steps=num_steps, generator=g,
|
| 293 |
+
anchor=anchor, **kw)
|
| 294 |
+
anchor = lat[:, -1:] # newest clean frame anchors the next chunk
|
| 295 |
+
if world_anchor_weight > 0 and world_anchor is not None:
|
| 296 |
+
w = world_anchor_weight
|
| 297 |
+
anchor = (1 - w) * anchor + w * world_anchor.to(anchor.device,
|
| 298 |
+
anchor.dtype)
|
| 299 |
+
lats.append(lat)
|
| 300 |
+
times.append(dt)
|
| 301 |
+
if log_every and (u + 1) % log_every == 0:
|
| 302 |
+
print(f' chunk {u+1}/{num_chunks}: {dt*1000:7.1f} ms '
|
| 303 |
+
f'({dt/chunk_frames*1000:6.1f} ms/latent-frame, '
|
| 304 |
+
f'cache {self.cache.num_tokens} tok)')
|
| 305 |
+
return torch.cat(lats, dim=1), times
|
wanstreamer/prompts.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt bank for teacher data generation.
|
| 2 |
+
|
| 3 |
+
Composition is deliberate rather than arbitrary. The deployment story is a
|
| 4 |
+
persistent world W that a stream of events continues, so the bank is weighted
|
| 5 |
+
towards content with a stable subject and continuous, non-cut motion -- that is
|
| 6 |
+
the distribution the student has to stay in over a long rollout. Hard cuts,
|
| 7 |
+
crowds and rapid scene changes are exactly what a block-causal model with a
|
| 8 |
+
sliding K/V window cannot represent, so they are kept out.
|
| 9 |
+
|
| 10 |
+
Roughly half is human subjects (the papers' interaction use case), the rest
|
| 11 |
+
animals, nature and urban scenes so the model does not collapse onto one mode.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
PEOPLE = [
|
| 15 |
+
"A woman with long dark hair speaking directly to the camera, natural facial expressions, soft studio lighting, shallow depth of field",
|
| 16 |
+
"A man in a grey sweater talking to the camera and gesturing calmly, warm indoor lighting, blurred bookshelf background",
|
| 17 |
+
"A young woman smiling and nodding while listening, soft window light from the left, neutral background",
|
| 18 |
+
"An older man with a short white beard telling a story to the camera, warm lamp light, cozy living room",
|
| 19 |
+
"A woman in a yellow raincoat looking around, light rain falling, overcast daylight, city street behind her",
|
| 20 |
+
"A chef in a white coat carefully plating a dish, overhead kitchen lighting, steam rising",
|
| 21 |
+
"A barista steaming milk behind a counter, morning light through a large window, cafe interior",
|
| 22 |
+
"A violinist playing on a small stage, warm spotlight, dark background",
|
| 23 |
+
"A woman reading a book by a window, turning a page, soft afternoon light, dust motes in the air",
|
| 24 |
+
"A man drinking coffee from a mug and looking out of a rainy window, muted daylight",
|
| 25 |
+
"A painter working on a canvas, brush strokes visible, studio skylight, paint-spattered apron",
|
| 26 |
+
"A woman practicing yoga on a mat, slow controlled movement, morning light on a wooden floor",
|
| 27 |
+
"A doctor in a white coat speaking to the camera in a bright clinic, professional lighting",
|
| 28 |
+
"A teacher writing on a whiteboard and turning to speak, classroom, fluorescent light",
|
| 29 |
+
"A man repairing a bicycle wheel in a garage, focused expression, work lamp lighting",
|
| 30 |
+
"A woman arranging flowers in a vase, bright kitchen, sunlight on the counter",
|
| 31 |
+
"A street musician playing guitar on a sidewalk, passersby blurred behind, golden hour",
|
| 32 |
+
"A potter shaping clay on a spinning wheel, hands wet with slip, warm workshop light",
|
| 33 |
+
"A woman in a wool coat walking slowly through a park in autumn, leaves falling",
|
| 34 |
+
"A man in a suit adjusting his tie in front of a mirror, hotel room, warm lamps",
|
| 35 |
+
"A child blowing bubbles in a garden, sunlight through the bubbles, summer afternoon",
|
| 36 |
+
"A woman laughing while talking on the phone, cafe interior, bokeh lights behind",
|
| 37 |
+
"A dancer moving slowly in an empty studio, mirrors and barre, cool daylight",
|
| 38 |
+
"A fisherman mending a net on a dock, weathered hands, overcast coastal light",
|
| 39 |
+
"A woman scientist looking into a microscope and then up at the camera, laboratory lighting",
|
| 40 |
+
"A man playing chess alone, moving a piece, window light across the board",
|
| 41 |
+
"A woman with curly hair singing into a studio microphone, headphones on, dim red light",
|
| 42 |
+
"An elderly woman knitting in an armchair, fireplace glow, quiet living room",
|
| 43 |
+
"A carpenter sanding a wooden plank, sawdust in the air, workshop window light",
|
| 44 |
+
"A woman tying her running shoes on a park bench, early morning, mist in the background",
|
| 45 |
+
"A man walking a dog along a canal, evening light, calm water",
|
| 46 |
+
"A woman applying makeup in front of a lit mirror, dressing room, soft bulbs",
|
| 47 |
+
"A librarian pulling a book from a high shelf, tall wooden stacks, warm reading lamps",
|
| 48 |
+
"A woman in a lab coat writing notes on a clipboard, bright modern interior",
|
| 49 |
+
"A man kneading dough on a floured counter, bakery, morning sun through a window",
|
| 50 |
+
"A woman in a denim jacket leaning on a railing overlooking a city, wind in her hair",
|
| 51 |
+
"A surfer in a wetsuit walking up a beach carrying a board, late afternoon sun",
|
| 52 |
+
"A woman gardening, planting a seedling into dark soil, bright overcast daylight",
|
| 53 |
+
"A man playing piano in a dim room, hands on the keys, single warm lamp",
|
| 54 |
+
"A woman photographer adjusting a camera lens, city rooftop, dusk",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
ANIMALS = [
|
| 58 |
+
"A ginger cat sitting on a windowsill, tail flicking, sunlight across its fur",
|
| 59 |
+
"A golden retriever running across a grassy field toward the camera, sunny day",
|
| 60 |
+
"A horse grazing in a misty meadow at dawn, breath visible in the cold air",
|
| 61 |
+
"A red fox stepping carefully through fresh snow in a pine forest",
|
| 62 |
+
"An owl slowly turning its head on a branch, dappled forest light",
|
| 63 |
+
"A pod of dolphins swimming just under a clear turquoise surface",
|
| 64 |
+
"A hummingbird hovering at a red flower, wings blurred, bright garden",
|
| 65 |
+
"A sheepdog herding sheep across a green hillside, overcast light",
|
| 66 |
+
"A tabby kitten batting at a dangling string, wooden floor, warm indoor light",
|
| 67 |
+
"A deer lifting its head alertly in a clearing, early morning fog",
|
| 68 |
+
"A parrot with green and red feathers preening on a branch, tropical foliage",
|
| 69 |
+
"A school of small silver fish turning together over a coral reef, shafts of sunlight",
|
| 70 |
+
"A bumblebee moving across lavender flowers, summer sunlight, shallow focus",
|
| 71 |
+
"An elephant slowly walking across a dry savannah, dust rising, late afternoon",
|
| 72 |
+
"A black cat stretching on a sunlit rug, slow movement, quiet room",
|
| 73 |
+
"A hawk perched on a fence post scanning the field, wind in the grass",
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
NATURE = [
|
| 77 |
+
"Waves rolling onto a rocky shore at sunset, spray catching the light",
|
| 78 |
+
"A mountain stream running over smooth stones, sunlight through overhanging leaves",
|
| 79 |
+
"Tall grass moving in the wind on a hillside, clouds drifting overhead",
|
| 80 |
+
"Snow falling slowly through a dense pine forest, quiet grey light",
|
| 81 |
+
"A waterfall in a green canyon, mist rising, shafts of sunlight",
|
| 82 |
+
"Autumn leaves drifting down onto a still pond, reflections rippling",
|
| 83 |
+
"A field of sunflowers swaying, bright blue sky with scattered clouds",
|
| 84 |
+
"Clouds moving over a desert mesa, long shadows, late afternoon",
|
| 85 |
+
"A lavender field in bloom, gentle wind, hazy summer sun",
|
| 86 |
+
"Fog rolling slowly through a valley of dark evergreens at dawn",
|
| 87 |
+
"Rain falling on the surface of a lake, concentric ripples, grey daylight",
|
| 88 |
+
"A campfire burning at dusk, sparks rising, forest silhouettes behind",
|
| 89 |
+
"Northern lights shifting slowly above a snowy ridge, deep blue night",
|
| 90 |
+
"A wheat field rippling in the wind under a dramatic evening sky",
|
| 91 |
+
"Palm fronds moving in a warm breeze against a bright tropical sky",
|
| 92 |
+
"Ice floating slowly down a wide grey river, bare winter trees on the bank",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
URBAN = [
|
| 96 |
+
"A quiet city street at night, wet asphalt reflecting neon signs, light rain",
|
| 97 |
+
"Steam rising from a manhole on a cold morning, cars passing slowly",
|
| 98 |
+
"A cafe terrace in the afternoon, awning moving slightly, people seated",
|
| 99 |
+
"A subway train arriving at a platform, motion blur, fluorescent light",
|
| 100 |
+
"Traffic crossing a bridge at dusk, headlights and taillights, city skyline behind",
|
| 101 |
+
"A narrow European alley with laundry hanging, warm afternoon light",
|
| 102 |
+
"Rain on a bus window, blurred city lights beyond the glass",
|
| 103 |
+
"A market stall with fresh produce, vendor arranging fruit, bright daylight",
|
| 104 |
+
"A quiet bookshop interior, dust in a shaft of sunlight, shelves of books",
|
| 105 |
+
"A rooftop at golden hour overlooking a dense city, antennas and water tanks",
|
| 106 |
+
"An empty basketball court at dusk, net moving slightly in the wind",
|
| 107 |
+
"A ferry crossing a harbour, gulls following, overcast light",
|
| 108 |
+
"A neon-lit ramen shop at night seen from the street, steam in the doorway",
|
| 109 |
+
"An old tram turning a corner on cobblestones, autumn trees along the street",
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
OBJECTS = [
|
| 113 |
+
"Coffee being poured into a glass cup, swirling crema, morning kitchen light",
|
| 114 |
+
"A candle flame flickering in a dark room, wax slowly melting",
|
| 115 |
+
"Ink diffusing into a glass of clear water, soft studio lighting",
|
| 116 |
+
"A record spinning on a turntable, needle in the groove, warm lamp light",
|
| 117 |
+
"Dough rising in a bowl in time lapse, kitchen window light",
|
| 118 |
+
"Water droplets running down a cold glass bottle, dark background",
|
| 119 |
+
"A mechanical watch movement ticking, extreme close-up, jeweller's lighting",
|
| 120 |
+
"Paint colours swirling together on a wet canvas, overhead studio light",
|
| 121 |
+
"Steam curling from a bowl of soup on a wooden table, warm evening light",
|
| 122 |
+
"A kite flying against a blue sky, string taut, wind moving the tail",
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
ALL = PEOPLE + ANIMALS + NATURE + URBAN + OBJECTS
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
from .prompts_ext import EXT_ALL, REPLACEMENTS, assert_no_minors # noqa: E402
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def prompt_bank():
|
| 132 |
+
bank = list(ALL)
|
| 133 |
+
for i, text in REPLACEMENTS.items():
|
| 134 |
+
bank[i] = text
|
| 135 |
+
bank += EXT_ALL
|
| 136 |
+
assert_no_minors(bank)
|
| 137 |
+
return bank
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def categories():
|
| 141 |
+
from . import prompts_ext as _x
|
| 142 |
+
return {'people': PEOPLE + _x.PEOPLE_EXT, 'animals': ANIMALS + _x.ANIMALS_EXT,
|
| 143 |
+
'nature': NATURE + _x.NATURE_EXT, 'urban': URBAN + _x.URBAN_EXT,
|
| 144 |
+
'objects': OBJECTS + _x.OBJECTS_EXT}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == '__main__':
|
| 148 |
+
for name, lst in categories().items():
|
| 149 |
+
print(f'{name:8s} {len(lst)}')
|
| 150 |
+
print(f'{"TOTAL":8s} {len(prompt_bank())} (v5 bank {len(ALL)} at indices 0-95)')
|
wanstreamer/prompts_ext.py
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-bank extension: the diversity fix FINDINGS §7 item 0b calls a blocker.
|
| 2 |
+
|
| 3 |
+
96 prompts / 432 clips is what v5 trained on, and it is the stated reason the
|
| 4 |
+
rolling-K/V work improved world_p0 and failed to generalise to the other three
|
| 5 |
+
worlds. At effective batch 64 the old bank is nine iterations per epoch. This
|
| 6 |
+
module adds 904 prompts to reach 1000.
|
| 7 |
+
|
| 8 |
+
TWO RULES THIS FILE FOLLOWS, AND WHY.
|
| 9 |
+
|
| 10 |
+
1. **It never touches indices 0-95.** `prompts.py` keeps its original bank
|
| 11 |
+
verbatim and `prompt_bank()` appends this module afterwards, so prompt 0 is
|
| 12 |
+
still the talking head, 44 the owl, 60 the waterfall and 82 the court. Those
|
| 13 |
+
four are `out/world_p{0,44,60,82}.pt` and every 1:1 comparison in FINDINGS;
|
| 14 |
+
renumbering them would silently invalidate the entire before/after evaluation
|
| 15 |
+
and the teacher clips already generated for them.
|
| 16 |
+
|
| 17 |
+
2. **Same composition discipline as the original.** Stable subject, continuous
|
| 18 |
+
non-cut motion, one scene per prompt. No hard cuts, no crowds, no rapid scene
|
| 19 |
+
changes -- a block-causal model with a sliding K/V window cannot represent
|
| 20 |
+
them, so training on them teaches nothing it can keep. Roughly half remains
|
| 21 |
+
human subjects for the interaction use case.
|
| 22 |
+
|
| 23 |
+
What is deliberately WIDER than the original bank, since narrowness was the
|
| 24 |
+
defect being fixed: many more trades, activities and interiors; the same kind of
|
| 25 |
+
subject under different times of day, weather and light; more variation in
|
| 26 |
+
framing distance, in how much of the frame the subject occupies, and in how fast
|
| 27 |
+
the motion is. Content type, lighting and setting vary as independently as a
|
| 28 |
+
hand-written list can manage, so the student does not learn that (say) a
|
| 29 |
+
workshop implies warm light.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
# --------------------------------------------------------------------- people
|
| 33 |
+
# Direct-to-camera and conversational. The papers' interaction case, and the
|
| 34 |
+
# thing mid-stream text switching will eventually have to stay inside, so it is
|
| 35 |
+
# the largest single block.
|
| 36 |
+
TALKING = [
|
| 37 |
+
"A woman in a green blouse speaking to the camera and pausing to think, soft key light, plain grey backdrop",
|
| 38 |
+
"A man with round glasses explaining something to the camera, hands moving in front of him, warm office lighting",
|
| 39 |
+
"A young man in a hooded sweatshirt talking casually to the camera, bedroom behind him, string lights out of focus",
|
| 40 |
+
"A woman with short silver hair speaking calmly from an armchair, window light from the right, quiet room",
|
| 41 |
+
"A man with a full beard laughing mid-sentence while talking to the camera, kitchen background, morning light",
|
| 42 |
+
"A woman in a blazer presenting to the camera with confident posture, softbox lighting, dark backdrop",
|
| 43 |
+
"A man in a plaid shirt leaning forward as he speaks, workshop behind him, overhead work lights",
|
| 44 |
+
"A woman with braided hair nodding and answering, cafe table, afternoon light through a window",
|
| 45 |
+
"A man in a turtleneck speaking quietly to the camera, dim room, a single lamp to one side",
|
| 46 |
+
"A woman in hospital scrubs talking to the camera, corridor blurred behind her, cool ceiling light",
|
| 47 |
+
"A man in a denim jacket gesturing while telling a story, brick wall behind, late afternoon sun",
|
| 48 |
+
"A woman with freckles smiling and speaking, close framing, natural window light, white wall",
|
| 49 |
+
"An older man in a cardigan speaking slowly and thoughtfully, bookshelves behind, warm reading lamp",
|
| 50 |
+
"A woman in a striped shirt raising her eyebrows as she makes a point, soft even studio light",
|
| 51 |
+
"A man with a shaved head speaking directly to camera, arms folded, grey seamless background",
|
| 52 |
+
"A woman in a knitted jumper cupping a mug while talking, sofa, soft lamp light, evening",
|
| 53 |
+
"A young woman with glasses reading aloud and glancing up at the camera, desk lamp, dark room",
|
| 54 |
+
"A man in a chef's jacket describing something to the camera, stainless kitchen behind, bright light",
|
| 55 |
+
"A woman in a high-collared coat speaking outdoors, breath faintly visible, overcast winter light",
|
| 56 |
+
"A man seated at a desk turning from a monitor to speak to the camera, blue screen glow on his face",
|
| 57 |
+
"A woman with long red hair tilting her head while listening, soft rim light, dark background",
|
| 58 |
+
"A man in a linen shirt speaking on a balcony, sea out of focus behind him, bright midday sun",
|
| 59 |
+
"A woman in a lab coat explaining a result to the camera, whiteboard behind, fluorescent light",
|
| 60 |
+
"An older woman with reading glasses on a chain speaking warmly, conservatory, filtered daylight",
|
| 61 |
+
"A man in a mechanic's overall wiping his hands while talking, garage, harsh overhead lamp",
|
| 62 |
+
"A woman in a silk scarf speaking with animated expressions, hotel lobby, warm ambient light",
|
| 63 |
+
"A young man with curly hair pausing and starting again mid-sentence, plain wall, flat daylight",
|
| 64 |
+
"A woman in a puffer jacket talking to the camera in falling snow, grey afternoon light",
|
| 65 |
+
"A man in a suit jacket without a tie speaking calmly, glass office, city blurred behind",
|
| 66 |
+
"A woman with a nose ring smiling and speaking, record shop behind her, warm tungsten light",
|
| 67 |
+
"A man in a fleece speaking from inside a tent doorway, dawn light on the fabric",
|
| 68 |
+
"A woman in a black polo neck speaking to camera in profile then turning to face it, dramatic side light",
|
| 69 |
+
"A man in a rugby shirt talking while catching his breath, park behind him, bright overcast sky",
|
| 70 |
+
"A woman in a tailored coat speaking on a station platform, train lights behind, evening",
|
| 71 |
+
"An older man with a walking stick seated on a bench, speaking to the camera, dappled park light",
|
| 72 |
+
"A woman in a chef's apron tasting from a spoon then talking to the camera, warm kitchen light",
|
| 73 |
+
"A man with a moustache speaking while leaning against a doorframe, hallway light behind him",
|
| 74 |
+
"A woman in a summer dress speaking in a garden, wind moving her hair, hazy afternoon sun",
|
| 75 |
+
"A young woman in a sports top speaking after exercise, gym behind her, cool overhead light",
|
| 76 |
+
"A man in a wool hat speaking outdoors at dusk, streetlight coming on behind him",
|
| 77 |
+
"A woman in a paint-stained shirt speaking in a studio, canvases behind, north-facing skylight",
|
| 78 |
+
"A man in a collarless shirt speaking with his hands clasped, softly lit interior, shallow focus",
|
| 79 |
+
"A woman with a pixie cut speaking energetically, colourful mural behind her, bright daylight",
|
| 80 |
+
"A man in a heavy coat speaking on a bridge, river and grey sky behind him",
|
| 81 |
+
"A woman seated cross-legged on a floor cushion talking to the camera, low warm light",
|
| 82 |
+
"A man in a bow tie speaking with precise gestures, wood-panelled room, warm lamps",
|
| 83 |
+
"A woman in a raincoat speaking under an umbrella, rain visible, muted grey daylight",
|
| 84 |
+
"A young man in a football shirt speaking excitedly, garden fence behind him, evening sun",
|
| 85 |
+
"A woman in a velvet jacket speaking under stage lighting, dark auditorium behind",
|
| 86 |
+
"A man with grey stubble speaking quietly by a window, rain running down the glass",
|
| 87 |
+
"A woman in a cycling jersey speaking while holding a helmet, roadside, bright morning",
|
| 88 |
+
"A man in a fisherman's jumper speaking on a harbour wall, boats out of focus behind",
|
| 89 |
+
"A woman in a graduation gown speaking to the camera and smiling, stone building behind",
|
| 90 |
+
"A man in a dressing gown speaking with a mug in hand, morning kitchen, low sun",
|
| 91 |
+
"A woman in a fitted blazer speaking in front of a bookcase, warm practical lamps",
|
| 92 |
+
"A man in a hi-vis vest speaking on a building site, cranes out of focus, hard daylight",
|
| 93 |
+
"A woman with silver earrings speaking closely to the camera, deep shadow on one side",
|
| 94 |
+
"A man in a cricket jumper speaking on a lawn, long shadows, late golden light",
|
| 95 |
+
"A woman in a hijab speaking calmly to the camera, soft window light, neutral interior",
|
| 96 |
+
"A man in a beanie speaking in a stairwell, cool fluorescent light overhead",
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
# Trades and craft. Continuous, repetitive, hand-scale motion with a fixed
|
| 100 |
+
# subject -- close to ideal for a sliding-window model, and almost absent from
|
| 101 |
+
# the original bank.
|
| 102 |
+
TRADES = [
|
| 103 |
+
"A blacksmith hammering glowing metal on an anvil, sparks scattering, dark forge",
|
| 104 |
+
"A glassblower turning a gather of molten glass, furnace glow on their face",
|
| 105 |
+
"A cobbler stitching the welt of a leather shoe, work lamp, cluttered bench",
|
| 106 |
+
"A watchmaker fitting a tiny gear with tweezers, loupe in one eye, bright task light",
|
| 107 |
+
"A tailor pinning fabric on a dress form, chalk marks, daylight through a tall window",
|
| 108 |
+
"A weaver passing a shuttle through a loom, coloured threads, warm workshop light",
|
| 109 |
+
"A luthier planing the top of a guitar body, curls of wood falling, window light",
|
| 110 |
+
"A bookbinder pressing a spine in a clamp, glue brush at hand, soft overhead light",
|
| 111 |
+
"A calligrapher drawing a long stroke with a broad nib, ink pooling, desk lamp",
|
| 112 |
+
"A printmaker rolling ink across a plate, hand press behind, bright studio",
|
| 113 |
+
"A stonemason chiselling a block of limestone, dust in the air, open yard light",
|
| 114 |
+
"A cooper hammering a hoop onto a barrel, wood shavings underfoot, dim workshop",
|
| 115 |
+
"A farrier shaping a horseshoe over a small anvil, stable doorway light behind",
|
| 116 |
+
"A sign painter drawing a gold letter with a fine brush, glass shopfront, street light behind",
|
| 117 |
+
"A neon bender heating a glass tube over a flame, dark workshop, orange glow",
|
| 118 |
+
"A ceramicist trimming a leather-hard bowl on a wheel, clay ribbons curling away",
|
| 119 |
+
"A jeweller soldering a ring with a small torch, bright pinpoint flame, dark bench",
|
| 120 |
+
"A leatherworker burnishing an edge with a wooden tool, warm bench lamp",
|
| 121 |
+
"A knife sharpener drawing a blade across a wet stone, steady rhythm, window light",
|
| 122 |
+
"An upholsterer stretching fabric over a chair frame and tacking it down, workshop light",
|
| 123 |
+
"A cabinetmaker cutting a dovetail with a fine saw, sawdust, clear north light",
|
| 124 |
+
"A basket weaver bending willow into a rim, hands working steadily, shaded porch",
|
| 125 |
+
"A candlemaker dipping wicks into wax, slow repeated motion, dim warm room",
|
| 126 |
+
"A cheesemaker turning a wheel on a wooden shelf, cool cellar light",
|
| 127 |
+
"A brewer stirring a mash tun with a long paddle, steam rising, industrial lighting",
|
| 128 |
+
"A cooper's apprentice sanding a stave, focused, dust catching a shaft of sunlight",
|
| 129 |
+
"A tattoo artist working on a forearm, gloved hands, bright directional lamp",
|
| 130 |
+
"A barber trimming a beard with clippers, mirror behind, warm barbershop light",
|
| 131 |
+
"A florist wiring a stem into an arrangement, cool shop light, buckets of flowers",
|
| 132 |
+
"A milliner shaping felt over a wooden hat block with steam, dim workroom",
|
| 133 |
+
"A glazier scoring a sheet of glass and snapping it cleanly, workshop daylight",
|
| 134 |
+
"A locksmith cutting a key on a machine, sparks and metal dust, harsh work light",
|
| 135 |
+
"A saddler stitching leather with two needles, saddle clamped upright, warm light",
|
| 136 |
+
"A restorer cleaning varnish from an oil painting with a swab, raking light",
|
| 137 |
+
"A model maker gluing a tiny part with tweezers, magnifier lamp, dark room",
|
| 138 |
+
"A clockmaker winding a movement and listening to it, quiet workshop, side light",
|
| 139 |
+
"A stained-glass artist leading a panel together, coloured light falling on the bench",
|
| 140 |
+
"A woodturner shaping a bowl on a lathe, shavings flying, focused work light",
|
| 141 |
+
"A shoemaker hammering a heel into place, last held between the knees, dim shop",
|
| 142 |
+
"An engraver cutting a line into a copper plate with a burin, bright angled lamp",
|
| 143 |
+
"A bell founder scraping a mould surface smooth, dusty foundry, high windows",
|
| 144 |
+
"A thatcher combing reed into place on a roof, bright overcast sky behind",
|
| 145 |
+
"A sailmaker feeding heavy canvas through an industrial machine, loft daylight",
|
| 146 |
+
"A wheelwright fitting a spoke into a wooden hub, open workshop door, bright outside",
|
| 147 |
+
"A gunsmith checkering a walnut stock with a fine file, bench lamp, dark room",
|
| 148 |
+
"A potter centring a lump of clay, wet hands, slip running, warm studio",
|
| 149 |
+
"A silversmith planishing a bowl with a small hammer, rhythmic ringing, dim bench",
|
| 150 |
+
"A bowyer scraping the belly of a longbow, long even strokes, window light",
|
| 151 |
+
"A glass engraver working a wheel against a goblet, water dripping, bright lamp",
|
| 152 |
+
"A tanner working oil into a hide with a cloth, dim tannery, high dusty light",
|
| 153 |
+
"A parchment maker scraping a stretched skin with a curved blade, cool daylight",
|
| 154 |
+
"A mosaic artist setting tesserae into wet mortar, bright overhead studio light",
|
| 155 |
+
"A gilder laying gold leaf onto a frame with a brush, still air, warm lamp",
|
| 156 |
+
"A blacksmith quenching a hot blade in oil, steam and flame flaring briefly",
|
| 157 |
+
"A carpenter marking a mortise with a gauge, pencil behind the ear, soft daylight",
|
| 158 |
+
"A pipe organ builder voicing a metal pipe, quiet church interior, tall windows",
|
| 159 |
+
"A rope maker twisting strands on a walk, long perspective, flat afternoon light",
|
| 160 |
+
"A knife maker grinding a bevel on a belt sander, sparks streaming, dark shop",
|
| 161 |
+
"A puppet maker articulating a wooden joint, tools on the bench, warm lamp",
|
| 162 |
+
"A book conservator flattening a page under weights, gloves, cool even light",
|
| 163 |
+
"A brass polisher buffing a lamp on a wheel, cloth blur, dim workshop",
|
| 164 |
+
"A dyer lifting fabric from an indigo vat, deep blue dripping, shaded courtyard",
|
| 165 |
+
"A furniture restorer rubbing wax into a tabletop, circular motion, warm light",
|
| 166 |
+
"A lens grinder polishing an optic on a spinning pad, water film, bright bench",
|
| 167 |
+
"A hat blocker brushing a felt brim, steam kettle beside, dim workroom",
|
| 168 |
+
"A screen printer pulling a squeegee across a screen, bright even studio light",
|
| 169 |
+
"A cobbler's wheel spinning as a sole is trimmed, dust and leather smell implied",
|
| 170 |
+
"A bronze caster pouring molten metal into a mould, intense orange glow, dark foundry",
|
| 171 |
+
"A chair caner threading rush through a seat frame, patient hands, porch light",
|
| 172 |
+
"A stone carver dusting chips from a half-finished relief, raking sunlight",
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
# Food preparation. Hands, steam, liquids and repetitive motion at a fixed
|
| 176 |
+
# camera -- and a very different texture and colour statistic from the studio
|
| 177 |
+
# talking heads above.
|
| 178 |
+
KITCHEN = [
|
| 179 |
+
"A cook folding an omelette in a pan, gas flame beneath, warm kitchen light",
|
| 180 |
+
"A baker scoring a loaf before it goes into the oven, flour on the crust, morning light",
|
| 181 |
+
"A chef julienning carrots quickly and evenly, steel bench, bright overhead light",
|
| 182 |
+
"A pastry chef piping cream onto a tart, steady hands, cool even light",
|
| 183 |
+
"A cook tossing vegetables in a wok over a high flame, steam and smoke rising",
|
| 184 |
+
"A barista pouring a rosetta into a flat white, close framing, cafe window light",
|
| 185 |
+
"A person kneading pasta dough and folding it over, wooden board, soft daylight",
|
| 186 |
+
"A chef spooning sauce around a plate, tweezers at hand, dark restaurant pass",
|
| 187 |
+
"A cook cracking eggs into a bowl one after another, marble counter, bright light",
|
| 188 |
+
"A person rolling sushi on a bamboo mat, precise movements, cool clean light",
|
| 189 |
+
"A baker dusting a tray of pastries with icing sugar, warm bakery light",
|
| 190 |
+
"A chef searing a steak, smoke rising, flames occasionally licking up",
|
| 191 |
+
"A person stirring risotto slowly in a wide pan, steam, evening kitchen light",
|
| 192 |
+
"A cook grating cheese over a bowl of pasta, warm domestic light",
|
| 193 |
+
"A pastry chef tempering chocolate on a marble slab, spatula sweeping, cool light",
|
| 194 |
+
"A person pressing tortillas and laying them on a hot griddle, steam rising",
|
| 195 |
+
"A chef deboning a fish with a thin blade, clean stainless surface, bright light",
|
| 196 |
+
"A cook ladling soup into a bowl, steam curling up, warm evening light",
|
| 197 |
+
"A person peeling apples in one long ribbon, kitchen table, window light",
|
| 198 |
+
"A baker shaping baguettes on a floured bench, rhythmic, early morning light",
|
| 199 |
+
"A cook flipping pancakes on a griddle, batter bubbling, bright kitchen",
|
| 200 |
+
"A person whisking egg whites in a copper bowl, steady circular motion, soft light",
|
| 201 |
+
"A chef torching the top of a creme brulee, flame passing over sugar, dark counter",
|
| 202 |
+
"A cook pulling noodles by hand, stretching and folding, flour dust, bright light",
|
| 203 |
+
"A person pouring batter into a hot waffle iron, steam escaping the seam",
|
| 204 |
+
"A chef arranging thin slices of fish on a plate, tweezers, focused task light",
|
| 205 |
+
"A cook stirring a large pot of stew with a wooden spoon, steam, warm light",
|
| 206 |
+
"A person icing a cake with a palette knife, turntable rotating slowly, soft light",
|
| 207 |
+
"A tea master pouring hot water over leaves in a small pot, steam, quiet dim room",
|
| 208 |
+
"A cook shucking oysters on ice, knife twisting, cold blue-tinted light",
|
| 209 |
+
"A person rolling dumplings and pleating the edges, board of flour, bright light",
|
| 210 |
+
"A chef straining pasta into a colander, steam billowing up, kitchen light",
|
| 211 |
+
"A person grinding spices in a mortar and pestle, steady rhythm, warm light",
|
| 212 |
+
"A cook basting a roast with a spoon, oven door open, warm interior glow",
|
| 213 |
+
"A bartender stirring a drink in a mixing glass, ice ringing, dim bar light",
|
| 214 |
+
"A person pouring olive oil in a thin stream over a salad, bright daylight",
|
| 215 |
+
"A cook flipping a crepe in a wide pan, quick wrist motion, warm kitchen",
|
| 216 |
+
"A person spooning yoghurt into a bowl and adding berries, morning window light",
|
| 217 |
+
"A chef plating a dessert with a quenelle of ice cream, cold plate, dark background",
|
| 218 |
+
"A person pouring coffee from a stovetop pot into a small cup, morning light",
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
# Music and performance. Continuous articulated motion, strong rhythmic
|
| 222 |
+
# structure, and a wide range of lighting from dark stages to bright rehearsal
|
| 223 |
+
# rooms.
|
| 224 |
+
MUSIC = [
|
| 225 |
+
"A cellist drawing a long bow stroke, eyes closed, warm rehearsal room light",
|
| 226 |
+
"A drummer playing a steady groove, sticks blurring, dim practice space",
|
| 227 |
+
"A pianist's hands moving across the keys, close framing, single warm lamp",
|
| 228 |
+
"A saxophonist playing under a single spotlight, smoke in the beam, dark club",
|
| 229 |
+
"A singer holding a long note at a microphone, eyes shut, deep red stage light",
|
| 230 |
+
"A guitarist bending a string on an electric guitar, amp glowing behind, dark stage",
|
| 231 |
+
"A harpist plucking with both hands, gilded frame catching warm light",
|
| 232 |
+
"A flautist playing in a bright rehearsal room, music stand, tall windows",
|
| 233 |
+
"A double bass player walking a line, upright instrument, dim jazz club",
|
| 234 |
+
"A violinist tuning and then beginning to play, warm practice room light",
|
| 235 |
+
"A DJ moving a fader and nodding to the beat, coloured lights sweeping behind",
|
| 236 |
+
"A conductor raising both hands and cueing an entry, dark hall, focused light",
|
| 237 |
+
"An accordionist playing on a stone step, warm late afternoon sun",
|
| 238 |
+
"A banjo player picking quickly on a wooden porch, bright overcast light",
|
| 239 |
+
"A trumpeter emptying a valve and lifting the horn to play, dim stage",
|
| 240 |
+
"A choir singer holding a folder and singing, stained glass light behind",
|
| 241 |
+
"A tabla player's hands moving rapidly on the drums, warm floor lamp",
|
| 242 |
+
"A koto player plucking strings, seated on a mat, soft diffuse daylight",
|
| 243 |
+
"A church organist's feet moving on the pedalboard, dim gallery light",
|
| 244 |
+
"A busker playing a fiddle in an underpass, hard directional light, tiled walls",
|
| 245 |
+
"A harmonica player cupping the instrument, eyes closed, warm bar light",
|
| 246 |
+
"A bagpiper playing on a windy hillside, tartan moving, grey daylight",
|
| 247 |
+
"A marimba player's mallets moving across the bars, bright rehearsal light",
|
| 248 |
+
"A sitar player sitting cross-legged, hands on the frets, dim warm room",
|
| 249 |
+
"A synth player adjusting a knob while a pad sustains, blue and purple glow",
|
| 250 |
+
"A bass guitarist locked in with the drums, stage haze, moving lights",
|
| 251 |
+
"A ballet dancer rising onto pointe and holding it, mirrored studio, cool light",
|
| 252 |
+
"A tap dancer's shoes striking a wooden floor, sharp rhythm, warm stage light",
|
| 253 |
+
"A flamenco dancer turning with a fan, red dress, dark stage, hard side light",
|
| 254 |
+
"A contemporary dancer rolling slowly across a floor, bare studio, north light",
|
| 255 |
+
"A puppeteer working a marionette on a small stage, warm footlights",
|
| 256 |
+
"A magician turning a coin over their knuckles, close framing, dark background",
|
| 257 |
+
"A mime moving slowly against an invisible wall, plain backdrop, flat light",
|
| 258 |
+
"A juggler keeping three clubs moving, park behind, golden hour light",
|
| 259 |
+
"A circus aerialist turning slowly on a hoop, dark tent, single beam",
|
| 260 |
+
"An actor delivering a monologue on a bare stage, single overhead light",
|
| 261 |
+
"A stand-up comic gesturing at a microphone, brick wall behind, warm spot",
|
| 262 |
+
"A street performer painting themselves still as a statue, bright square daylight",
|
| 263 |
+
"A beatboxer close to a microphone, hand cupped, dark room, rim light",
|
| 264 |
+
"A ukulele player strumming on a beach at sunset, warm backlight",
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
# Sport and physical movement. Fast, whole-body motion with a stable frame --
|
| 268 |
+
# the opposite end of the motion-magnitude axis from the talking heads.
|
| 269 |
+
SPORT = [
|
| 270 |
+
"A swimmer doing front crawl in a pool lane, underwater view, shafts of light",
|
| 271 |
+
"A boxer working a speed bag, rhythmic blur, dusty gym light",
|
| 272 |
+
"A climber reaching for a hold on an indoor wall, chalk dust, bright colour",
|
| 273 |
+
"A runner on a track rounding a bend, morning light, long shadows",
|
| 274 |
+
"A rower pulling on an indoor machine, steady breathing, cool gym light",
|
| 275 |
+
"A skateboarder rolling slowly across an empty park bowl, late afternoon sun",
|
| 276 |
+
"A tennis player serving, ball toss at the top of frame, bright daylight",
|
| 277 |
+
"A footballer juggling a ball on their instep, grass pitch, evening light",
|
| 278 |
+
"A weightlifter setting up over a barbell and taking a breath, harsh gym light",
|
| 279 |
+
"A gymnast holding a handstand on a beam, quiet hall, high windows",
|
| 280 |
+
"A yoga teacher moving slowly into a low lunge, warm studio, wooden floor",
|
| 281 |
+
"A cyclist climbing out of the saddle on a hill road, low sun behind",
|
| 282 |
+
"A kayaker paddling across flat water at dawn, mist on the surface",
|
| 283 |
+
"A surfer paddling out through a small wave, bright morning glare",
|
| 284 |
+
"A skier carving through soft snow, spray rising, blue shadows",
|
| 285 |
+
"A snowboarder traversing slowly across a slope, overcast flat light",
|
| 286 |
+
"A fencer lunging and recovering, white kit, cool hall lighting",
|
| 287 |
+
"A judo practitioner gripping a partner's jacket and shifting weight, dojo light",
|
| 288 |
+
"An archer drawing a bow and holding the anchor, field behind, soft daylight",
|
| 289 |
+
"A basketball player shooting free throws alone, empty gym, high windows",
|
| 290 |
+
"A dancer skipping rope quickly, dust on the floor, single hanging bulb",
|
| 291 |
+
"A martial artist practising a slow form in a park at sunrise, mist",
|
| 292 |
+
"A horse and rider cantering along a beach, spray from the hooves, low sun",
|
| 293 |
+
"A rock climber on a sea cliff reaching up, sea moving far below, bright light",
|
| 294 |
+
"A hiker stepping up onto a rock and pausing, wide valley behind, hazy light",
|
| 295 |
+
"A pole vaulter running in with the pole, stadium, bright floodlights",
|
| 296 |
+
"A table tennis player rallying, fast bat movement, flat indoor light",
|
| 297 |
+
"A golfer swinging through and holding the finish, fairway, low golden light",
|
| 298 |
+
"A rugby player passing along the line, wet grass, floodlit evening",
|
| 299 |
+
"A goalkeeper diving to save a low shot, spray of turf, bright daylight",
|
| 300 |
+
"A sprinter setting into blocks and breathing out, stadium, warm evening light",
|
| 301 |
+
"A cricketer bowling in from the crease, dusty pitch, hard midday sun",
|
| 302 |
+
"A diver walking to the end of a board and pausing, still pool below",
|
| 303 |
+
"A parkour athlete stepping along a low wall, quiet street, morning light",
|
| 304 |
+
"A wheelchair racer pushing along a track, hands blurring, bright daylight",
|
| 305 |
+
"A person on a climbing treadmill, steady hand movement, dim gym",
|
| 306 |
+
"An ice skater gliding across an outdoor rink, string lights above, dusk",
|
| 307 |
+
"A speed skater in a low crouch on a long oval, cool indoor light",
|
| 308 |
+
"A trail runner descending a rocky path, dust rising, dappled forest light",
|
| 309 |
+
"A stand-up paddleboarder crossing a still lake at dawn, reflections",
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
# Work, study and screens. Small motion against a fixed subject, and the screen
|
| 313 |
+
# glow gives a light source the rest of the bank does not have.
|
| 314 |
+
WORK = [
|
| 315 |
+
"A programmer typing quickly, code reflected in their glasses, dark room",
|
| 316 |
+
"An architect drawing on a large sheet with a scale rule, drafting lamp",
|
| 317 |
+
"An editor scrubbing through footage on a timeline, dual monitors, dim suite",
|
| 318 |
+
"An accountant turning pages of a ledger and marking a figure, desk lamp",
|
| 319 |
+
"A translator speaking into a headset in a booth, small light on the desk",
|
| 320 |
+
"A radio host adjusting a microphone arm and leaning in, dim studio, red light",
|
| 321 |
+
"An air traffic controller watching a screen and speaking, dark room, green glow",
|
| 322 |
+
"A watch repairer's hands under a magnifier, bright ring light",
|
| 323 |
+
"A designer sketching on a tablet with a stylus, bright screen, dark surround",
|
| 324 |
+
"A student highlighting a textbook at a library desk, green banker's lamp",
|
| 325 |
+
"A teacher marking papers at a kitchen table late at night, single lamp",
|
| 326 |
+
"A cartographer tracing a coastline on a large map, bright even light",
|
| 327 |
+
"A stenographer's hands moving on a small keyboard, courtroom, flat light",
|
| 328 |
+
"A tailor's assistant measuring a sleeve and noting it down, shop daylight",
|
| 329 |
+
"A curator adjusting the angle of a small object in a display case, gallery light",
|
| 330 |
+
"A sound engineer moving faders on a large console, meters flickering, dim room",
|
| 331 |
+
"A photographer reviewing frames on a camera back, studio strobes behind",
|
| 332 |
+
"A pilot running through a checklist in a cockpit, instrument glow, dusk outside",
|
| 333 |
+
"A ship's officer plotting a course on a chart table, red night lighting",
|
| 334 |
+
"A dispatcher speaking into a headset, wall of screens behind, cool light",
|
| 335 |
+
"A journalist typing on a laptop in a hotel room, window light, city outside",
|
| 336 |
+
"A clerk stamping documents one after another, post office, flat overhead light",
|
| 337 |
+
"A biologist labelling sample tubes in a rack, cold laboratory light",
|
| 338 |
+
"A weather forecaster gesturing at a map, studio lighting, blue backdrop",
|
| 339 |
+
"A pharmacist counting tablets into a tray, bright counter light",
|
| 340 |
+
"A bank teller counting notes through a machine, quiet interior, even light",
|
| 341 |
+
"A barista's colleague writing the specials on a chalkboard, morning shop light",
|
| 342 |
+
"A tattoo shop receptionist flipping through an appointment book, warm lamp",
|
| 343 |
+
"A watch dealer examining a dial with a loupe, dark velvet tray, bright spot",
|
| 344 |
+
"An auctioneer taking bids and gesturing, warm room light, wood panelling",
|
| 345 |
+
"A locksmith at a van workbench cutting a key, roadside, overcast light",
|
| 346 |
+
"A gardener at a potting bench filling trays with compost, greenhouse light",
|
| 347 |
+
"A vet writing notes after an examination, clinic, cool ceiling light",
|
| 348 |
+
"A driving instructor pointing at the road ahead from the passenger seat, daylight",
|
| 349 |
+
"A projectionist threading film through a projector, dim booth, warm lamp",
|
| 350 |
+
"A bookseller shelving new stock and straightening spines, warm shop light",
|
| 351 |
+
"A museum conservator brushing dust from a fossil, bright angled lamp",
|
| 352 |
+
"A drone pilot watching a controller screen in a field, bright overcast sky",
|
| 353 |
+
"A seamstress feeding fabric through an industrial machine, task light",
|
| 354 |
+
"A call centre worker adjusting a headset and smiling while speaking, flat light",
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
# Outdoors and travel. Wide scenes with a person in them, moving daylight and
|
| 358 |
+
# weather -- deliberately the hardest lighting in the bank.
|
| 359 |
+
OUTDOOR = [
|
| 360 |
+
"A hiker adjusting the straps of a rucksack on a ridge, wind, bright hazy light",
|
| 361 |
+
"A woman standing at the edge of a cliff looking out to sea, wind in her coat",
|
| 362 |
+
"A man rowing a small wooden boat across a still lake at dawn",
|
| 363 |
+
"A traveller waiting on a rural platform with a suitcase, low winter sun",
|
| 364 |
+
"A cyclist stopping to drink from a bottle on a country lane, dappled shade",
|
| 365 |
+
"A shepherd walking behind a flock along a stone-walled track, grey light",
|
| 366 |
+
"A woman reading on a picnic blanket, grass moving in the wind, bright afternoon",
|
| 367 |
+
"A man skimming a stone across flat water, ripples spreading, evening light",
|
| 368 |
+
"A woman photographing a mountain range from a viewpoint, cold clear light",
|
| 369 |
+
"A man chopping firewood outside a cabin, breath visible, low winter sun",
|
| 370 |
+
"A woman pitching a tent in a meadow, fabric catching the wind, golden hour",
|
| 371 |
+
"A man fishing from a riverbank, line casting out, mist on the water",
|
| 372 |
+
"A woman walking a coastal path with a dog, sea and sky behind, bright wind",
|
| 373 |
+
"A man refilling a water bottle from a mountain stream, cold clear light",
|
| 374 |
+
"A woman feeding chickens in a farmyard, morning light through a gate",
|
| 375 |
+
"A man on a ladder picking apples into a canvas bag, orchard, soft autumn light",
|
| 376 |
+
"A woman digging in an allotment bed, spade turning soil, flat overcast light",
|
| 377 |
+
"A man loading hay bales onto a trailer, dust in the air, hard summer sun",
|
| 378 |
+
"A woman leading a horse out of a stable, warm doorway light behind",
|
| 379 |
+
"A man scraping ice from a windscreen, breath visible, blue pre-dawn light",
|
| 380 |
+
"A woman shovelling snow from a path, steady rhythm, grey winter light",
|
| 381 |
+
"A man sitting on a rock eating a sandwich, wide valley behind, hazy light",
|
| 382 |
+
"A woman crossing a wooden footbridge over a stream, dappled forest light",
|
| 383 |
+
"A man launching a kayak from a shingle beach, small waves, overcast",
|
| 384 |
+
"A woman in waders casting a fly line, river bend, low golden light",
|
| 385 |
+
"A man tending a beehive in a veil and gloves, smoke drifting, bright meadow",
|
| 386 |
+
"A woman sketching a landscape in a small notebook, wind moving the pages",
|
| 387 |
+
"A man walking along a railway embankment at dusk, long shadows",
|
| 388 |
+
"A woman sitting on a harbour wall watching boats, gulls overhead, bright grey",
|
| 389 |
+
"A man tying a boat to a mooring ring, water slapping the stone, soft light",
|
| 390 |
+
"A woman running along a beach at low tide, wet sand reflecting the sky",
|
| 391 |
+
"A man setting up a telescope in a field at dusk, first stars appearing",
|
| 392 |
+
"A woman collecting shells at the tideline, low sun behind her",
|
| 393 |
+
"A man walking through tall reeds at the edge of a marsh, warm evening light",
|
| 394 |
+
"A woman climbing a stile in a hedgerow, sheep field beyond, bright overcast",
|
| 395 |
+
"A man carrying a canoe on his shoulders down to the water, dappled shade",
|
| 396 |
+
"A woman stopping to look at a map at a trail junction, forest light",
|
| 397 |
+
"A man raking leaves into a pile in a garden, autumn light, low sun",
|
| 398 |
+
"A woman hanging washing on a line, sheets moving in the wind, bright day",
|
| 399 |
+
"A man watering a vegetable garden with a can, evening light, long shadows",
|
| 400 |
+
]
|
| 401 |
+
|
| 402 |
+
# Quiet domestic interiors. Small motion, warm practical light, and the
|
| 403 |
+
# strongest test of whether identity and fine detail hold over a long rollout.
|
| 404 |
+
DOMESTIC = [
|
| 405 |
+
"A woman folding laundry on a bed, slow steady motion, afternoon window light",
|
| 406 |
+
"A man ironing a shirt, steam rising, kitchen light behind him",
|
| 407 |
+
"A woman watering houseplants on a windowsill, low sun through the leaves",
|
| 408 |
+
"A man tuning a radio dial, warm lamp, evening living room",
|
| 409 |
+
"A woman brushing her hair in front of a window, soft morning light",
|
| 410 |
+
"A man lighting a fire in a wood stove, flames catching, dim room",
|
| 411 |
+
"A woman writing a letter at a desk, fountain pen, single lamp, night",
|
| 412 |
+
"A man polishing shoes on a newspaper, circular motion, kitchen light",
|
| 413 |
+
"A woman sorting through a box of old photographs, warm attic light",
|
| 414 |
+
"A man making a bed, sheet billowing and settling, bright bedroom",
|
| 415 |
+
"A woman doing a jigsaw puzzle at a table, pieces fitting, lamp light",
|
| 416 |
+
"A man winding a mantel clock, quiet room, afternoon light",
|
| 417 |
+
"A woman painting her nails at a small table, focused, window light",
|
| 418 |
+
"A man sharpening a pencil with a small knife, shavings falling, desk lamp",
|
| 419 |
+
"A woman kneading dough at a kitchen table, flour dust in the light",
|
| 420 |
+
"A man repotting a plant, soil spilling onto newspaper, bright kitchen",
|
| 421 |
+
"A woman lighting candles on a table one by one, room darkening around",
|
| 422 |
+
"A man sewing a button onto a coat, thread pulled through, lamp light",
|
| 423 |
+
"A woman stacking books onto a shelf, dust motes in a sunbeam",
|
| 424 |
+
"A man cleaning a camera lens with a cloth, careful movements, window light",
|
| 425 |
+
"A woman drinking tea and watching rain on the window, muted grey light",
|
| 426 |
+
"A man cutting flowers and putting them in a jar, bright kitchen counter",
|
| 427 |
+
"A woman rocking slowly in a chair with a blanket, firelight on her face",
|
| 428 |
+
"A man scrolling through records in a crate, warm shop-like room light",
|
| 429 |
+
"A woman plaiting her own hair in front of a mirror, morning bathroom light",
|
| 430 |
+
"A man wiping down a kitchen counter, steady sweeps, evening light",
|
| 431 |
+
"A woman putting on earrings in front of a mirror, warm bulbs around the frame",
|
| 432 |
+
"A man setting a table, placing cutlery one piece at a time, low sun",
|
| 433 |
+
"A woman stretching on a mat in a living room, morning light on the floor",
|
| 434 |
+
"A man feeding a cat from a tin, cat winding around his legs, kitchen light",
|
| 435 |
+
"A woman opening curtains and light flooding into a room",
|
| 436 |
+
"A man carrying a mug through a hallway, warm light spilling from a doorway",
|
| 437 |
+
"A woman folding paper into a small shape, precise hands, desk lamp",
|
| 438 |
+
"A man restringing a guitar, winding the tuner, warm living room light",
|
| 439 |
+
"A woman shelling peas into a bowl, steady rhythm, kitchen window light",
|
| 440 |
+
"A man dusting a bookshelf with a cloth, sunbeam full of motes",
|
| 441 |
+
"A woman knitting while watching something off camera, lamp beside her",
|
| 442 |
+
"A man buttoning a coat by the front door, hallway light, about to leave",
|
| 443 |
+
"A woman putting a record on a turntable and lowering the needle, warm light",
|
| 444 |
+
"A man drying dishes and stacking them, window over the sink, evening light",
|
| 445 |
+
]
|
| 446 |
+
|
| 447 |
+
# Clinical, scientific and ceremonial. Cool and neutral lighting, and content
|
| 448 |
+
# types with almost no representation in the original bank.
|
| 449 |
+
INSTITUTIONAL = [
|
| 450 |
+
"A surgeon tying a suture knot, gloved hands, bright overhead theatre light",
|
| 451 |
+
"A nurse taking a blood pressure reading, cuff inflating, clinic light",
|
| 452 |
+
"A dentist examining with a small mirror, bright chair lamp, blue gloves",
|
| 453 |
+
"A physiotherapist guiding a shoulder through a slow range of motion",
|
| 454 |
+
"A radiographer positioning a patient's arm, cool scanner room light",
|
| 455 |
+
"A paramedic checking a monitor in the back of an ambulance, harsh strip light",
|
| 456 |
+
"A researcher pipetting into a row of wells, bright bench light, blue gloves",
|
| 457 |
+
"A chemist swirling a flask over a stirrer, coloured solution, fume hood light",
|
| 458 |
+
"A microscopist adjusting focus and looking up, dim room, illuminated stage",
|
| 459 |
+
"An astronomer adjusting a telescope mount in a dome, red safety light",
|
| 460 |
+
"A geologist splitting a rock with a hammer and examining the face, field light",
|
| 461 |
+
"A marine biologist lifting a sample net onto a deck, bright sea glare",
|
| 462 |
+
"An archaeologist brushing soil from a fragment in a trench, hard sun",
|
| 463 |
+
"A vet listening to a dog's chest with a stethoscope, calm clinic light",
|
| 464 |
+
"A lab technician loading a centrifuge and closing the lid, cool light",
|
| 465 |
+
"An engineer testing a circuit board with a probe, bench lamp, dark room",
|
| 466 |
+
"A metrologist reading a dial gauge on a granite table, clean bright light",
|
| 467 |
+
"A wind tunnel technician adjusting a model, cavernous space, work lights",
|
| 468 |
+
"A librarian scanning a barcode and stamping a date, quiet counter light",
|
| 469 |
+
"A judge writing a note at the bench, wood panelling, warm formal light",
|
| 470 |
+
"A priest lighting altar candles, tall stained glass windows behind",
|
| 471 |
+
"A monk sweeping a courtyard slowly, early light on stone",
|
| 472 |
+
"A bride adjusting a veil in front of a tall mirror, soft daylight",
|
| 473 |
+
"A tea ceremony host whisking matcha in a bowl, tatami room, diffuse light",
|
| 474 |
+
"A calligrapher preparing ink on a stone before writing, quiet dim room",
|
| 475 |
+
"A flag bearer holding a pole steady in the wind, bright overcast sky",
|
| 476 |
+
"A choir conductor lowering their hands at the end of a phrase, church light",
|
| 477 |
+
"A glassblower's assistant opening a furnace door, intense orange light spilling",
|
| 478 |
+
"A miner adjusting a headlamp underground, dust in the beam, black surround",
|
| 479 |
+
"A lighthouse keeper climbing a spiral stair, small windows, cold daylight",
|
| 480 |
+
]
|
| 481 |
+
|
| 482 |
+
# Older adults. Motion statistics, pacing and skin/hair detail that the rest of
|
| 483 |
+
# the bank does not cover.
|
| 484 |
+
#
|
| 485 |
+
# NOTE: this bank contains no minors, by request. Nothing here depicts or
|
| 486 |
+
# implies anyone under 18. See `assert_no_minors()` at the bottom of this file,
|
| 487 |
+
# which enforces it as a test rather than a convention.
|
| 488 |
+
ELDERS = [
|
| 489 |
+
"An older woman tending roses with secateurs, sunhat, bright garden light",
|
| 490 |
+
"An older man doing a crossword with a pen, reading glasses, lamp light",
|
| 491 |
+
"An elderly couple walking arm in arm down a lane, autumn trees, low sun",
|
| 492 |
+
"An older woman playing an upright piano from memory, warm parlour light",
|
| 493 |
+
"An older man feeding pigeons from a bench, city square, flat daylight",
|
| 494 |
+
"An elderly woman threading a needle carefully, bright window light",
|
| 495 |
+
"An older man polishing a vintage car bonnet, garage, hard work light",
|
| 496 |
+
"An elderly woman arranging photographs in an album, warm table lamp",
|
| 497 |
+
"An older man tying fishing flies at a bench, magnifier, focused lamp",
|
| 498 |
+
"An elderly woman watering a windowbox, morning light on the street below",
|
| 499 |
+
"An older man kneading bread dough slowly on a scrubbed table, morning light",
|
| 500 |
+
"An elderly woman winding wool into a ball from a skein, lamp beside her",
|
| 501 |
+
"An older man walking a dog slowly along a canal path, low autumn sun",
|
| 502 |
+
"An elderly woman pruning a bonsai with small shears, bright bench light",
|
| 503 |
+
"An older man carving a walking stick handle with a knife, porch light",
|
| 504 |
+
"An elderly woman practising scales on a violin, warm sitting room",
|
| 505 |
+
"An older man cleaning a pair of spectacles with a cloth, window light",
|
| 506 |
+
"An elderly woman stirring jam in a wide pan, steam rising, kitchen light",
|
| 507 |
+
"An older man setting up a chessboard piece by piece, warm lamp",
|
| 508 |
+
"An elderly woman doing slow tai chi in a park at dawn, mist on the grass",
|
| 509 |
+
]
|
| 510 |
+
|
| 511 |
+
PEOPLE_EXT = (TALKING + TRADES + KITCHEN + MUSIC + SPORT + WORK + OUTDOOR
|
| 512 |
+
+ DOMESTIC + INSTITUTIONAL + ELDERS)
|
| 513 |
+
|
| 514 |
+
# -------------------------------------------------------------------- animals
|
| 515 |
+
ANIMALS_EXT = [
|
| 516 |
+
"A snow leopard picking its way along a rocky ledge, cold overcast light",
|
| 517 |
+
"A grey wolf trotting across a frozen lake, low winter sun behind",
|
| 518 |
+
"A brown bear turning over a rock in a shallow river, bright forest light",
|
| 519 |
+
"A lynx sitting still in deep snow, ears twitching, flat grey light",
|
| 520 |
+
"A stag standing in bracken and lifting its head, autumn morning mist",
|
| 521 |
+
"A herd of bison moving slowly through long grass, dust and low sun",
|
| 522 |
+
"A giraffe reaching up to strip leaves from a thorn tree, hard midday light",
|
| 523 |
+
"A zebra flicking its tail in dry grass, heat haze, bright savannah",
|
| 524 |
+
"A hippopotamus surfacing slowly in a muddy river, ears flicking water",
|
| 525 |
+
"A rhinoceros walking through dust at sunset, long shadows behind",
|
| 526 |
+
"A cheetah scanning the plain from a low mound, wind in the grass",
|
| 527 |
+
"A lioness lying in shade and yawning, dappled acacia light",
|
| 528 |
+
"A meerkat standing upright on a mound, alert, bright desert light",
|
| 529 |
+
"A sloth moving one arm slowly along a branch, humid green light",
|
| 530 |
+
"An orangutan swinging slowly between branches, dappled canopy light",
|
| 531 |
+
"A gorilla sitting and chewing a stem, deep forest shade",
|
| 532 |
+
"A lemur sunning itself on a branch, arms out, bright morning light",
|
| 533 |
+
"A red panda walking along a mossy log, soft overcast forest light",
|
| 534 |
+
"A koala shifting position in a eucalyptus fork, hazy blue-grey light",
|
| 535 |
+
"A kangaroo grazing then lifting its head, dry paddock, low golden sun",
|
| 536 |
+
"An otter rolling onto its back in a river, water beading on fur",
|
| 537 |
+
"A beaver swimming across a still pond at dusk, wake spreading behind",
|
| 538 |
+
"A badger emerging from a sett at twilight, low blue light",
|
| 539 |
+
"A hedgehog moving through leaf litter, torchlit, dark surround",
|
| 540 |
+
"A squirrel turning a nut over in its paws on a branch, autumn light",
|
| 541 |
+
"A hare sitting motionless in a ploughed field, low morning sun",
|
| 542 |
+
"A mole hill of soil shifting as something moves beneath, bright lawn",
|
| 543 |
+
"A field mouse climbing a grass stem, shallow focus, warm meadow light",
|
| 544 |
+
"A bat hanging and stretching one wing, dim cave light",
|
| 545 |
+
"A porcupine walking slowly across pine needles, dappled forest light",
|
| 546 |
+
"A raccoon washing something in shallow water, night, cool moonlight",
|
| 547 |
+
"A skunk foraging along a fence line at dusk, low warm light",
|
| 548 |
+
"A wild boar rooting in mud in a dark wood, shafts of light",
|
| 549 |
+
"A mountain goat standing on a narrow ledge, bright alpine light",
|
| 550 |
+
"An ibex silhouetted on a ridge at sunset, warm rim light",
|
| 551 |
+
"A camel walking across dunes, long shadow, hard low sun",
|
| 552 |
+
"A llama chewing and looking toward the camera, high altitude clear light",
|
| 553 |
+
"An alpaca in a misty field at dawn, breath visible",
|
| 554 |
+
"A donkey shaking its head in a dusty paddock, bright afternoon",
|
| 555 |
+
"A cow chewing cud in a barn doorway, bright light outside, dark within",
|
| 556 |
+
"A calf standing up unsteadily in straw, warm barn light",
|
| 557 |
+
"A pig rooting in mud in a farmyard, flat overcast light",
|
| 558 |
+
"A goat balancing on a fallen tree trunk, dappled woodland light",
|
| 559 |
+
"A ram standing in heather on a hillside, wind, grey light",
|
| 560 |
+
"A sheepdog lying alert with its head on its paws, warm kitchen light",
|
| 561 |
+
"A border collie crouching low and watching sheep, bright field",
|
| 562 |
+
"A labrador shaking water from its coat after a swim, spray flying",
|
| 563 |
+
"A beagle following a scent along a hedgerow, nose down, soft light",
|
| 564 |
+
"A greyhound running flat out across a field, motion blur behind",
|
| 565 |
+
"A terrier digging enthusiastically in sand, bright beach light",
|
| 566 |
+
"A husky pulling on a harness in snow, breath steaming, blue shadows",
|
| 567 |
+
"A dachshund trotting along a pavement, low camera, warm evening light",
|
| 568 |
+
"A pug asleep on a sofa and twitching, warm lamp light",
|
| 569 |
+
"A great dane standing and stretching, tall against a doorway, hall light",
|
| 570 |
+
"A dog shaking itself dry in slow motion, droplets flying, bright light",
|
| 571 |
+
"A puppy sniffing at a flower in a garden, soft morning light",
|
| 572 |
+
"A siamese cat stalking slowly across a wooden floor, low sun",
|
| 573 |
+
"A maine coon cat grooming its tail, warm window light",
|
| 574 |
+
"A cat leaping onto a windowsill and settling, afternoon light",
|
| 575 |
+
"A cat's paw batting at a water surface in a bowl, ripples, kitchen light",
|
| 576 |
+
"A cat sleeping curled in a patch of sun, slow breathing",
|
| 577 |
+
"A cat watching birds through glass, tail twitching, bright window",
|
| 578 |
+
"A rabbit washing its face with both paws, hutch straw, soft light",
|
| 579 |
+
"A guinea pig eating a piece of vegetable, warm indoor light",
|
| 580 |
+
"A ferret exploring along a shelf, quick movements, dim room",
|
| 581 |
+
"A hamster running in a wheel, warm cage light, dark surround",
|
| 582 |
+
"A horse tossing its mane in a paddock, dust and low sun",
|
| 583 |
+
"A foal running alongside a mare in a field, bright morning",
|
| 584 |
+
"A horse drinking from a trough, ripples, hard midday light",
|
| 585 |
+
"A shire horse pulling a cart along a lane, dust, warm afternoon",
|
| 586 |
+
"An eagle perched on a crag, feathers ruffling in the wind, cold light",
|
| 587 |
+
"A kestrel hovering over a field, wings adjusting, bright overcast",
|
| 588 |
+
"A barn owl flying slowly along a hedge at dusk, pale against dark",
|
| 589 |
+
"A heron standing motionless in shallow water then striking, grey light",
|
| 590 |
+
"A kingfisher perched on a reed above a stream, bright dappled light",
|
| 591 |
+
"A robin hopping along a fence rail, cold winter light",
|
| 592 |
+
"A blackbird pulling a worm from a lawn, bright overcast",
|
| 593 |
+
"A swan gliding across still water, reflection perfect beneath",
|
| 594 |
+
"A duck landing on a pond, feet skimming the surface, spray",
|
| 595 |
+
"A goose stretching its wings on a riverbank, morning mist",
|
| 596 |
+
"A pelican folding its wings after landing on a post, bright coastal light",
|
| 597 |
+
"A puffin standing on a cliff edge, wind ruffling, grey sea behind",
|
| 598 |
+
"A penguin waddling across ice then sliding onto its belly, flat white light",
|
| 599 |
+
"A flamingo standing on one leg and preening, shallow pink water",
|
| 600 |
+
"A peacock slowly raising its tail, iridescent, bright garden light",
|
| 601 |
+
"A rooster crowing on a fence post, early morning light",
|
| 602 |
+
"A hen scratching in a dust bath, bright farmyard",
|
| 603 |
+
"A pigeon strutting along a window ledge, city light behind",
|
| 604 |
+
"A crow examining something on tarmac, overcast urban light",
|
| 605 |
+
"A magpie hopping across a lawn, bright morning",
|
| 606 |
+
"A woodpecker drumming on a trunk, dappled forest light",
|
| 607 |
+
"A swallow perched on a wire, wind ruffling, bright sky behind",
|
| 608 |
+
"A stork standing in a nest on a chimney, warm evening light",
|
| 609 |
+
"A hummingbird feeding at a feeder, wings a blur, bright garden",
|
| 610 |
+
"A parrot cracking a seed in its beak, tropical shade",
|
| 611 |
+
"A budgerigar preening on a perch, warm indoor light",
|
| 612 |
+
"A canary hopping between perches, bright window light",
|
| 613 |
+
"A seagull hovering into the wind above a harbour, grey bright sky",
|
| 614 |
+
"A tern diving toward the sea and pulling up, bright glare",
|
| 615 |
+
"A turtle swimming slowly over a reef, sunlight rippling on its shell",
|
| 616 |
+
"A sea turtle surfacing to breathe, calm water, bright light",
|
| 617 |
+
"A shoal of sardines turning as one, shafts of light through blue water",
|
| 618 |
+
"A manta ray gliding past, silhouetted against the surface",
|
| 619 |
+
"A reef shark cruising along a drop-off, deep blue water",
|
| 620 |
+
"A clownfish moving among anemone tentacles, bright reef light",
|
| 621 |
+
"A seahorse gripping a strand of weed, gentle current, soft light",
|
| 622 |
+
"An octopus changing colour across a rock, shallow clear water",
|
| 623 |
+
"A cuttlefish hovering and rippling its fins, dim blue water",
|
| 624 |
+
"A jellyfish pulsing slowly in dark water, translucent, single beam",
|
| 625 |
+
"A starfish moving imperceptibly across a rock pool, bright shallow water",
|
| 626 |
+
"A crab scuttling sideways across wet sand, low sun",
|
| 627 |
+
"A lobster backing into a crevice, dim underwater light",
|
| 628 |
+
"A seal hauling itself onto a rock, spray, bright coastal light",
|
| 629 |
+
"A sea lion turning underwater in a shaft of sunlight",
|
| 630 |
+
"A whale's tail lifting and sliding under the surface, grey sea",
|
| 631 |
+
"A dolphin leaping clear of the water, spray catching the sun",
|
| 632 |
+
"A frog inflating its throat on a lily pad, warm pond light",
|
| 633 |
+
"A tree frog gripping a wet leaf, rain dripping, green light",
|
| 634 |
+
"A newt swimming through pondweed, dappled shallow light",
|
| 635 |
+
"A chameleon moving one foot at a time along a branch, bright foliage",
|
| 636 |
+
"A gecko clinging to a wall and blinking, warm lamp light",
|
| 637 |
+
"A snake moving slowly across warm sand, low sun, long shadow",
|
| 638 |
+
"A python coiled and slowly shifting, dim terrarium light",
|
| 639 |
+
"An iguana basking on a rock, bright tropical sun",
|
| 640 |
+
"A tortoise walking slowly across grass, bright afternoon",
|
| 641 |
+
"A crocodile lying motionless with one eye open, muddy bank, hard light",
|
| 642 |
+
"A monarch butterfly opening and closing its wings on a flower, bright sun",
|
| 643 |
+
"A dragonfly hovering over water then darting away, bright reflections",
|
| 644 |
+
"A praying mantis turning its head, shallow focus, warm light",
|
| 645 |
+
"A spider repairing a web strand by strand, backlit dew",
|
| 646 |
+
"A snail moving along a wet leaf, slow trail, grey light",
|
| 647 |
+
"A colony of ants carrying leaf pieces along a branch, dappled light",
|
| 648 |
+
"A beetle climbing a grass stem, bright meadow, shallow depth of field",
|
| 649 |
+
"A moth resting on a lit window at night, wings still",
|
| 650 |
+
]
|
| 651 |
+
|
| 652 |
+
# --------------------------------------------------------------------- nature
|
| 653 |
+
NATURE_EXT = [
|
| 654 |
+
"A glacier calving slowly into a fjord, ice falling, flat grey light",
|
| 655 |
+
"Meltwater running in channels across blue glacier ice, bright midday sun",
|
| 656 |
+
"An iceberg drifting in still black water, pale under an overcast sky",
|
| 657 |
+
"Frost forming on a window pane, crystals spreading, cold dawn light",
|
| 658 |
+
"Icicles dripping steadily from a roof edge, bright winter sun",
|
| 659 |
+
"A frozen waterfall with water still running behind the ice, blue shade",
|
| 660 |
+
"Snow sliding off a pine branch in a clump, quiet forest",
|
| 661 |
+
"A blizzard blowing across an empty road, headlights faint, grey light",
|
| 662 |
+
"Wind-blown snow streaming off a mountain ridge, hard alpine light",
|
| 663 |
+
"A thaw stream cutting through old snow, bright spring light",
|
| 664 |
+
"Steam rising from a hot spring into cold air, blue morning light",
|
| 665 |
+
"A geyser building and then erupting, spray drifting, bright sky",
|
| 666 |
+
"Bubbling mud in a geothermal pool, sulphurous steam, flat light",
|
| 667 |
+
"Lava crusting over and cracking bright orange, dark night",
|
| 668 |
+
"Ash drifting from a volcanic vent, grey daylight, stark ground",
|
| 669 |
+
"Black sand beach with waves washing over it, high contrast light",
|
| 670 |
+
"A desert dune ridge with sand streaming off the crest, low sun",
|
| 671 |
+
"Heat shimmer over a salt flat, hard white light, distant mountains",
|
| 672 |
+
"A dust devil moving across dry ground, bright afternoon",
|
| 673 |
+
"A cactus standing against a deep blue desert sky, hard shadows",
|
| 674 |
+
"Rain falling on parched earth, dust puffing up, grey storm light",
|
| 675 |
+
"A flash flood running down a dry wash, muddy water, overcast",
|
| 676 |
+
"A canyon river seen from above, slow green water, deep shade",
|
| 677 |
+
"Sandstone walls glowing with reflected light in a slot canyon",
|
| 678 |
+
"A mesa at sunrise with mist in the valley below, warm light",
|
| 679 |
+
"Rolling fog spilling over a coastal ridge, late afternoon sun",
|
| 680 |
+
"A cloud inversion filling a valley at dawn, peaks above it",
|
| 681 |
+
"Cumulus clouds building over a plain, shadows moving across fields",
|
| 682 |
+
"A thunderstorm anvil lit from within, distant, dusk",
|
| 683 |
+
"Lightning branching across a night sky over open country",
|
| 684 |
+
"Rain sweeping visibly across a lake toward the camera, grey light",
|
| 685 |
+
"A rainbow forming over wet fields as the sun breaks through",
|
| 686 |
+
"Sunbeams breaking through cloud onto the sea, high contrast",
|
| 687 |
+
"A sunset burning orange behind silhouetted hills, clouds moving",
|
| 688 |
+
"The last light fading from a mountain face, cold blue shadow rising",
|
| 689 |
+
"Stars wheeling slowly above a dark treeline, deep night",
|
| 690 |
+
"The Milky Way over a still desert horizon, faint airglow",
|
| 691 |
+
"A full moon rising behind bare winter branches, cold light",
|
| 692 |
+
"Moonlight on rippling water, silver path, dark surround",
|
| 693 |
+
"Dawn light spreading across a frozen lake, pink to gold",
|
| 694 |
+
"A meadow of wildflowers moving in the wind, hazy summer sun",
|
| 695 |
+
"Poppies in a green field bending in a gust, bright overcast",
|
| 696 |
+
"A hillside of heather in flower, low cloud drifting over",
|
| 697 |
+
"Bluebells covering a woodland floor, dappled spring light",
|
| 698 |
+
"Long grass bending in waves across a hillside, low golden sun",
|
| 699 |
+
"A bamboo grove swaying, green filtered light, quiet",
|
| 700 |
+
"Aspen leaves trembling in a breeze, bright autumn yellow",
|
| 701 |
+
"Birch trunks white against dark forest, snow on the ground",
|
| 702 |
+
"A single oak in a field with cloud shadows passing over it",
|
| 703 |
+
"Moss and ferns on a damp forest floor, soft green light",
|
| 704 |
+
"Mushrooms on a fallen log, shallow focus, dim forest light",
|
| 705 |
+
"Mist hanging between conifers at first light, still air",
|
| 706 |
+
"Rain dripping from a leaf canopy, forest floor dark, green light",
|
| 707 |
+
"Sunlight moving across a forest floor as clouds pass",
|
| 708 |
+
"A stream running under exposed tree roots, dappled light",
|
| 709 |
+
"A beaver dam holding back a still pond, evening light",
|
| 710 |
+
"A reed bed moving in the wind at the edge of a lake, grey light",
|
| 711 |
+
"Water lilies on a still pond, insects skimming the surface",
|
| 712 |
+
"A mountain tarn perfectly reflecting the ridge above, still dawn",
|
| 713 |
+
"A river braiding across a gravel plain, seen wide, flat light",
|
| 714 |
+
"Rapids breaking white over boulders, spray, bright daylight",
|
| 715 |
+
"A waterfall plunging into a dark plunge pool, mist rising",
|
| 716 |
+
"A tidal estuary at low water, channels shining, wide grey sky",
|
| 717 |
+
"Waves breaking over a reef, spray blown back by offshore wind",
|
| 718 |
+
"A big swell rolling in under grey cloud, no horizon detail",
|
| 719 |
+
"Foam sliding back down a steep shingle beach, low sun",
|
| 720 |
+
"Rock pools reflecting the sky at low tide, bright light",
|
| 721 |
+
"Kelp moving in the surge in shallow clear water",
|
| 722 |
+
"Sea caves with light reflecting off the water onto the walls",
|
| 723 |
+
"A sea stack with waves washing around its base, evening light",
|
| 724 |
+
"Cliffs with seabirds wheeling below the top, bright overcast",
|
| 725 |
+
"A lighthouse beam sweeping through sea mist at night",
|
| 726 |
+
"A calm sea at dawn with no wind, pale gradient sky",
|
| 727 |
+
"Sunlight sparkling on choppy water, high contrast, hard light",
|
| 728 |
+
"A coral reef in shallow water, light rippling across it",
|
| 729 |
+
"Seagrass bending with the current, clear turquoise water",
|
| 730 |
+
"Rain dimpling the surface of a tropical lagoon, warm grey light",
|
| 731 |
+
"A mangrove root system in shallow water, dappled light",
|
| 732 |
+
"A salt marsh with channels filling on the tide, wide flat light",
|
| 733 |
+
"Sand ripples underwater with light patterns moving over them",
|
| 734 |
+
"A field of ripe barley moving in the wind, warm evening light",
|
| 735 |
+
"A vineyard on a slope with mist in the rows at dawn",
|
| 736 |
+
"An olive grove with silver leaves turning in the wind, hard sun",
|
| 737 |
+
"A tea plantation on terraced hills, low cloud, soft light",
|
| 738 |
+
"Rice paddies reflecting the sky, a slight breeze, flat light",
|
| 739 |
+
"A field of tulips in blocks of colour, bright overcast",
|
| 740 |
+
"An orchard in blossom with petals drifting down, soft sun",
|
| 741 |
+
"A hedgerow in autumn heavy with berries, low golden light",
|
| 742 |
+
"A stubble field after harvest with dust blowing, hard sun",
|
| 743 |
+
"A ploughed field with gulls following, grey winter light",
|
| 744 |
+
"A hay meadow with insects in the air, backlit summer light",
|
| 745 |
+
"A pumpkin field in October, low sun, long shadows",
|
| 746 |
+
"A cotton field with bolls moving in the wind, bright light",
|
| 747 |
+
"Sunflowers turning toward the light, blue sky, scattered cloud",
|
| 748 |
+
"A lavender row with bees working along it, hazy sun",
|
| 749 |
+
"A greenhouse interior with condensation running down the glass",
|
| 750 |
+
"Seedlings in trays with water droplets on the leaves, soft light",
|
| 751 |
+
"A compost heap steaming faintly in cold morning air",
|
| 752 |
+
"A garden pond with insects landing on the surface, dappled light",
|
| 753 |
+
"Ivy moving slightly on an old stone wall, overcast light",
|
| 754 |
+
"Cherry blossom against a grey sky, petals falling",
|
| 755 |
+
"Autumn leaves turning on a single maple, wind picking up",
|
| 756 |
+
"Bare branches against a white winter sky, snow beginning",
|
| 757 |
+
"Buds opening on a branch in early spring, soft light",
|
| 758 |
+
"A wildfire smoke haze turning the sun deep orange, dim light",
|
| 759 |
+
"Fresh snow untouched on a hillside at first light, blue shadows",
|
| 760 |
+
"A hail shower bouncing off a stone path, hard bright light",
|
| 761 |
+
"Wind flattening long grass in gusts across a headland",
|
| 762 |
+
"Dew on a spider web catching the low morning sun",
|
| 763 |
+
"Frost patterns on a fallen leaf, cold shallow light",
|
| 764 |
+
"A puddle freezing over, ice crystals spreading, dull light",
|
| 765 |
+
"Sea smoke rising off water on a freezing morning, low sun",
|
| 766 |
+
"A sandstorm approaching across flat desert, dimming the light",
|
| 767 |
+
"A meteor streaking across a dark star field, brief",
|
| 768 |
+
"Clouds racing across the moon, light coming and going",
|
| 769 |
+
"Bioluminescence glowing in breaking waves at night",
|
| 770 |
+
"A tide pool at dusk reflecting the last colour in the sky",
|
| 771 |
+
"Sun setting directly into the sea, path of light on the water",
|
| 772 |
+
"A high alpine glacier under hard blue sky, wind blowing snow",
|
| 773 |
+
"A crevasse field seen from above, deep blue shadow",
|
| 774 |
+
"A mountain hut with cloud streaming past, cold flat light",
|
| 775 |
+
"Scree slope with small stones sliding, hard midday sun",
|
| 776 |
+
"An alpine meadow with flowers below a snow peak, bright light",
|
| 777 |
+
"A rock face with water seeping and dripping, shaded",
|
| 778 |
+
"Wind-carved rock formations in a desert, low raking light",
|
| 779 |
+
"A limestone pavement with grass in the cracks, grey light",
|
| 780 |
+
"A peat bog with cotton grass moving in the wind, flat light",
|
| 781 |
+
"A chalk stream running clear over weed, bright dappled light",
|
| 782 |
+
"A weir with water pouring evenly over the lip, grey light",
|
| 783 |
+
"A canal reflecting overhanging trees, still, soft light",
|
| 784 |
+
"A flooded field with trees standing in water, grey calm light",
|
| 785 |
+
"A river in spate carrying branches, brown water, overcast",
|
| 786 |
+
"A dry riverbed with cracked mud, hard sun, heat shimmer",
|
| 787 |
+
"A spring bubbling up through sand, clear water, dappled light",
|
| 788 |
+
"A waterfall seen from behind through the falling curtain",
|
| 789 |
+
"Rain on a tin roof seen from beneath the eaves, grey light",
|
| 790 |
+
"A storm passing and light returning across a valley",
|
| 791 |
+
]
|
| 792 |
+
|
| 793 |
+
# ---------------------------------------------------------------------- urban
|
| 794 |
+
URBAN_EXT = [
|
| 795 |
+
"A cobbled street in the rain at night, single streetlamp, reflections",
|
| 796 |
+
"A wide avenue at dawn with almost no traffic, low sun down the street",
|
| 797 |
+
"A crossing signal changing and a few people stepping off the kerb",
|
| 798 |
+
"A revolving door turning slowly in a lobby, bright exterior behind",
|
| 799 |
+
"An escalator moving in an empty station concourse, cool light",
|
| 800 |
+
"A lift lobby with floor indicators changing, polished stone, even light",
|
| 801 |
+
"A parking garage with a car's headlights sweeping the pillars",
|
| 802 |
+
"A loading bay with a shutter rolling up, harsh sodium light",
|
| 803 |
+
"A fire escape on a brick wall with steam drifting past, night",
|
| 804 |
+
"A back alley with a single door light and rain falling",
|
| 805 |
+
"A laundrette at night with machines turning, fluorescent light",
|
| 806 |
+
"A late-night diner window seen from the street, warm interior",
|
| 807 |
+
"A bar interior with bottles backlit, dim and warm, quiet",
|
| 808 |
+
"A cinema foyer with carpet and dim wall lights, empty",
|
| 809 |
+
"A theatre auditorium with house lights slowly dimming",
|
| 810 |
+
"A concert hall stage empty with a single work light",
|
| 811 |
+
"A museum gallery with light from a high window across a floor",
|
| 812 |
+
"An art gallery corridor with spotlit walls, polished concrete",
|
| 813 |
+
"A cathedral nave with light falling through clerestory windows",
|
| 814 |
+
"A mosque interior with patterned light on the carpet",
|
| 815 |
+
"A temple courtyard with incense smoke drifting, warm light",
|
| 816 |
+
"A synagogue interior with warm lamps and wooden benches",
|
| 817 |
+
"A university quad with long shadows in late afternoon",
|
| 818 |
+
"A school corridor empty between classes, fluorescent light",
|
| 819 |
+
"A hospital corridor with a trolley passing, cool even light",
|
| 820 |
+
"An office floor after hours with a few desk lamps still on",
|
| 821 |
+
"A server room with rows of blinking lights, cold blue glow",
|
| 822 |
+
"A control room with banks of monitors, dim surround",
|
| 823 |
+
"A print works with a press running, harsh industrial light",
|
| 824 |
+
"A warehouse aisle with a forklift moving away, high bay lights",
|
| 825 |
+
"A shipping container yard with a crane moving, hard daylight",
|
| 826 |
+
"A dockside with a ship's hull filling the frame, grey light",
|
| 827 |
+
"A fishing harbour at dawn with boats returning, soft light",
|
| 828 |
+
"A marina with halyards moving and reflections shifting, bright light",
|
| 829 |
+
"A canal lock filling slowly, water churning, overcast",
|
| 830 |
+
"A railway yard with wagons being shunted, flat grey light",
|
| 831 |
+
"A level crossing with barriers lowering, evening light",
|
| 832 |
+
"A tram stop with a tram arriving, wet road, night",
|
| 833 |
+
"A bus depot with buses idling, exhaust visible, cold morning",
|
| 834 |
+
"An airport gate with a plane pushing back beyond the glass",
|
| 835 |
+
"A runway seen through heat haze, aircraft taxiing, hard sun",
|
| 836 |
+
"A motorway bridge with traffic streaming beneath at dusk",
|
| 837 |
+
"A toll booth at night with headlights approaching, sodium light",
|
| 838 |
+
"A petrol station forecourt at night, bright canopy, dark surround",
|
| 839 |
+
"A car wash with brushes and water on the windscreen from inside",
|
| 840 |
+
"A multi-storey rooftop car park at sunset, city beyond",
|
| 841 |
+
"A construction site with a crane slewing slowly, bright sky",
|
| 842 |
+
"Scaffolding on a building facade with sheeting moving in the wind",
|
| 843 |
+
"A demolition site with dust settling, hard afternoon light",
|
| 844 |
+
"A newly poured concrete floor being smoothed, work lights",
|
| 845 |
+
"A road crew laying tarmac, steam rising, bright daylight",
|
| 846 |
+
"A window cleaner's cradle moving down a glass facade",
|
| 847 |
+
"A city square with pigeons and a fountain running, bright light",
|
| 848 |
+
"A fountain switching off and the water settling, evening",
|
| 849 |
+
"A park bandstand empty with leaves blowing across, autumn light",
|
| 850 |
+
"A municipal rose garden with a sprinkler turning, bright sun",
|
| 851 |
+
"A cemetery with long shadows between stones, low winter sun",
|
| 852 |
+
"A canal towpath with a narrowboat passing slowly, soft light",
|
| 853 |
+
"A pedestrian bridge with the river running below, grey light",
|
| 854 |
+
"A riverside walk at blue hour with lights coming on",
|
| 855 |
+
"A skyline seen across water with lights reflecting, dusk",
|
| 856 |
+
"A rooftop garden with plants moving in the wind, city behind",
|
| 857 |
+
"A balcony with washing drying and traffic sound implied, warm light",
|
| 858 |
+
"An apartment block facade with lights on in some windows, night",
|
| 859 |
+
"A stairwell with light falling from a high window, dust in the air",
|
| 860 |
+
"A hotel corridor with patterned carpet and wall sconces",
|
| 861 |
+
"A hotel room window with curtains moving and city beyond",
|
| 862 |
+
"A launderette window seen from outside at night, warm inside",
|
| 863 |
+
"A bakery window with trays being filled from behind, morning",
|
| 864 |
+
"A butcher's shop with a counter light and hanging scales",
|
| 865 |
+
"A greengrocer arranging crates on the pavement, bright morning",
|
| 866 |
+
"A flower stall with buckets of stems, awning shadow, bright light",
|
| 867 |
+
"A fish market slab with ice and a hose running, cold light",
|
| 868 |
+
"A spice market with sacks open, warm dusty light",
|
| 869 |
+
"A night market with hanging bulbs and steam, warm and dark",
|
| 870 |
+
"A food truck window with steam and a warm interior light, night",
|
| 871 |
+
"A vending machine glowing in an empty passage at night",
|
| 872 |
+
"A phone box lit from within on an empty street",
|
| 873 |
+
"A bus shelter with rain running down the glass, night",
|
| 874 |
+
"A pedestrian underpass with tiled walls and a strip light",
|
| 875 |
+
"A graffiti wall with paint still wet, bright daylight",
|
| 876 |
+
"A mural being painted from a cherry picker, bright sky",
|
| 877 |
+
"A shopfront shutter with peeling paint, hard afternoon sun",
|
| 878 |
+
"A barber's pole turning outside a shop, evening light",
|
| 879 |
+
"A neon sign flickering on above a doorway, dusk",
|
| 880 |
+
"A cinema marquee with letters lit, rain on the pavement",
|
| 881 |
+
"An arcade with machine lights and no one there, dark surround",
|
| 882 |
+
"A bowling alley lane with pins resetting, low warm light",
|
| 883 |
+
"A swimming pool hall empty with light rippling on the ceiling",
|
| 884 |
+
"An ice rink empty with the surface being resurfaced, cold light",
|
| 885 |
+
"A gym at night with one row of lights on, machines still",
|
| 886 |
+
"A boxing gym with a bag swinging slightly, dusty light",
|
| 887 |
+
"A dance studio empty with mirrors and a barre, north light",
|
| 888 |
+
"A recording studio live room empty, warm lamps, cables",
|
| 889 |
+
"A radio tower blinking on a hill at dusk, city lights below",
|
| 890 |
+
"A water tower against an evening sky, birds circling",
|
| 891 |
+
"A power station cooling tower with steam drifting, flat light",
|
| 892 |
+
"A wind turbine turning slowly against grey cloud",
|
| 893 |
+
"A solar farm with panels tracking, hard bright light",
|
| 894 |
+
"An electricity pylon line receding across fields, low sun",
|
| 895 |
+
"A railway tunnel mouth with a train emerging, hard contrast",
|
| 896 |
+
"A metro platform with a train's lights approaching in the tunnel",
|
| 897 |
+
"A ticket hall with departure boards updating, cool light",
|
| 898 |
+
"A cable car ascending above a city, cabin swaying slightly",
|
| 899 |
+
"A funicular climbing a steep slope, town below, bright light",
|
| 900 |
+
"A ferris wheel turning slowly at dusk, lights coming on",
|
| 901 |
+
"A carousel turning with lights and horses, evening",
|
| 902 |
+
"A fairground at night with lights moving, dark between",
|
| 903 |
+
"A pier stretching out to sea with lamps lit, blue hour",
|
| 904 |
+
"A promenade with waves breaking over the sea wall, grey light",
|
| 905 |
+
"A beach hut row with paint bright under a flat overcast sky",
|
| 906 |
+
"A seaside arcade front at night, saturated light, wet pavement",
|
| 907 |
+
"A harbour wall with waves bursting over it, storm light",
|
| 908 |
+
"A coastal road with mist blowing across, headlights faint",
|
| 909 |
+
"A mountain village street with snow banked at the sides",
|
| 910 |
+
"A hill town at dusk with lights coming on in the houses",
|
| 911 |
+
"A desert highway with heat shimmer and no traffic",
|
| 912 |
+
"A dirt road with dust hanging after a vehicle has passed",
|
| 913 |
+
"A tree-lined avenue with light flickering through the canopy",
|
| 914 |
+
]
|
| 915 |
+
|
| 916 |
+
# -------------------------------------------------------------------- objects
|
| 917 |
+
OBJECTS_EXT = [
|
| 918 |
+
"Honey drizzling slowly from a dipper into a jar, warm backlight",
|
| 919 |
+
"Milk being poured into black coffee, clouds blooming, bright light",
|
| 920 |
+
"A tea bag steeping and colour spreading through hot water",
|
| 921 |
+
"Butter melting in a hot pan and sliding across it",
|
| 922 |
+
"Chocolate being poured into a mould, glossy, cool studio light",
|
| 923 |
+
"Caramel being pulled and folded on a marble slab, warm light",
|
| 924 |
+
"Cheese being sliced with a wire, clean edge, bright light",
|
| 925 |
+
"Bread being torn open with steam escaping, warm light",
|
| 926 |
+
"A soft-boiled egg being opened and yolk running, bright light",
|
| 927 |
+
"Batter being whisked until smooth, bowl rotating, soft light",
|
| 928 |
+
"Herbs being chopped finely, blade rocking, bright board",
|
| 929 |
+
"Citrus being zested with a fine grater, oil misting, close",
|
| 930 |
+
"Garlic being crushed under a knife blade, bright kitchen light",
|
| 931 |
+
"A jar of preserves being sealed and the lid popping, warm light",
|
| 932 |
+
"Popcorn popping in a covered pan, lid lifting slightly",
|
| 933 |
+
"Rice being rinsed in a bowl, water clouding, bright light",
|
| 934 |
+
"Noodles being lifted from broth with chopsticks, steam rising",
|
| 935 |
+
"Sparkling water being poured, bubbles rising, backlit glass",
|
| 936 |
+
"Wine being swirled in a glass, legs running down the side",
|
| 937 |
+
"Beer being poured with a head forming, warm bar light",
|
| 938 |
+
"Ice cracking as liquid is poured over it, close, dark background",
|
| 939 |
+
"A cocktail being strained into a chilled glass, dim bar light",
|
| 940 |
+
"A teapot pouring a thin steady stream, steam, soft light",
|
| 941 |
+
"A moka pot bubbling on a hob, coffee rising, warm light",
|
| 942 |
+
"An espresso extraction running into a cup, close, machine light",
|
| 943 |
+
"Steam wand frothing milk, vortex forming, cafe light",
|
| 944 |
+
"A kettle boiling with steam pouring from the spout, kitchen light",
|
| 945 |
+
"Oil shimmering in a hot pan just before smoking",
|
| 946 |
+
"A candle being lit and the flame settling, dark room",
|
| 947 |
+
"A match striking and flaring, close, black background",
|
| 948 |
+
"Incense smoke curling upward in still air, single beam of light",
|
| 949 |
+
"A wood fire collapsing and sparks rising, dark surround",
|
| 950 |
+
"Embers glowing and pulsing in a grate, dark room",
|
| 951 |
+
"A gas hob ring igniting, blue flame steadying",
|
| 952 |
+
"A blowtorch flame playing across metal, dark workshop",
|
| 953 |
+
"Molten metal being poured into a mould, intense glow",
|
| 954 |
+
"Sparks streaming from an angle grinder, dark background",
|
| 955 |
+
"A welding arc flaring and dimming, deep shadow around",
|
| 956 |
+
"Solder melting and flowing along a joint, bright close light",
|
| 957 |
+
"A soldering iron tip being cleaned on a sponge, steam",
|
| 958 |
+
"A drill bit cutting into wood, shavings spiralling out",
|
| 959 |
+
"A saw blade cutting through a board, dust rising, work light",
|
| 960 |
+
"A plane taking a long shaving off a board, curling away",
|
| 961 |
+
"Sandpaper moving across wood, dust and changing sheen",
|
| 962 |
+
"A chisel paring a shaving from end grain, bright bench light",
|
| 963 |
+
"A clock's second hand sweeping, close, warm light",
|
| 964 |
+
"A pendulum swinging in a case, brass catching lamp light",
|
| 965 |
+
"An hourglass with sand running, backlit, dark background",
|
| 966 |
+
"A metronome ticking back and forth, warm lamp",
|
| 967 |
+
"A music box cylinder turning and plucking the comb, close",
|
| 968 |
+
"A record needle settling into the groove, close, warm light",
|
| 969 |
+
"A cassette reel turning behind a window, warm light",
|
| 970 |
+
"A film projector reel turning, dust in the beam, dark booth",
|
| 971 |
+
"A camera shutter opening and closing in slow motion",
|
| 972 |
+
"A lens aperture blades closing down, close, studio light",
|
| 973 |
+
"A typewriter key striking the page, ink impression, lamp light",
|
| 974 |
+
"A fountain pen nib laying a wet line of ink, close",
|
| 975 |
+
"Ink being drawn into a pen from a bottle, warm light",
|
| 976 |
+
"A pencil being sharpened, shavings curling, bright light",
|
| 977 |
+
"A brush loading paint from a palette, studio light",
|
| 978 |
+
"Watercolour blooming into wet paper, bright even light",
|
| 979 |
+
"A palette knife spreading thick paint on canvas, raking light",
|
| 980 |
+
"Ink being rolled out on a slab with a brayer, even light",
|
| 981 |
+
"A screen print squeegee lifting to reveal a print, bright light",
|
| 982 |
+
"Clay being wedged on a bench, hands pressing, workshop light",
|
| 983 |
+
"A glaze being poured over a bisque bowl, cool studio light",
|
| 984 |
+
"A kiln peephole glowing orange, dark surround",
|
| 985 |
+
"Thread being wound onto a bobbin, close, task light",
|
| 986 |
+
"A needle pulling thread through fabric, close, lamp light",
|
| 987 |
+
"Scissors cutting a long clean line through fabric, bright light",
|
| 988 |
+
"A knot being tied in rope, hands working, flat light",
|
| 989 |
+
"A shoelace being threaded and pulled tight, close",
|
| 990 |
+
"Wool being spun onto a spindle, fibre drawing out, warm light",
|
| 991 |
+
"A loom shuttle passing and the beater knocking up, workshop light",
|
| 992 |
+
"Dye spreading through white fabric in a bath, overhead light",
|
| 993 |
+
"Fabric being steamed and creases falling out, backlit",
|
| 994 |
+
"A paper sheet being folded sharply, crisp edge, bright light",
|
| 995 |
+
"A book page turning in a draught, warm reading light",
|
| 996 |
+
"Dust motes drifting through a shaft of light in a still room",
|
| 997 |
+
"Rain running down a window pane, lights beyond out of focus",
|
| 998 |
+
"Condensation forming on a cold glass, drops beginning to run",
|
| 999 |
+
"Soap bubbles forming and popping in a sink, bright light",
|
| 1000 |
+
"Water swirling down a drain, close, bright light",
|
| 1001 |
+
"A drop falling into still water, crown and ripples, dark background",
|
| 1002 |
+
"Oil and water separating in a jar, backlit, slow motion",
|
| 1003 |
+
"Sand running through fingers onto a pile, low sun",
|
| 1004 |
+
"A magnet dragging iron filings into lines, bright even light",
|
| 1005 |
+
"A pendulum of a Newton's cradle clicking, warm desk light",
|
| 1006 |
+
"A spinning top slowing and beginning to wobble, dark surface",
|
| 1007 |
+
"A domino line falling in sequence, bright even light",
|
| 1008 |
+
"A balloon slowly deflating, surface wrinkling, plain background",
|
| 1009 |
+
"A feather falling slowly through still air, dark background",
|
| 1010 |
+
"A soap film shifting colours across a wire loop, dark surround",
|
| 1011 |
+
"Frost melting off a windscreen in the morning sun",
|
| 1012 |
+
]
|
| 1013 |
+
|
| 1014 |
+
EXT_ALL = PEOPLE_EXT + ANIMALS_EXT + NATURE_EXT + URBAN_EXT + OBJECTS_EXT
|
| 1015 |
+
|
| 1016 |
+
# One in-place substitution in the ORIGINAL bank. Index 20 was
|
| 1017 |
+
# "A child blowing bubbles in a garden" -- this bank is to contain no minors,
|
| 1018 |
+
# and deleting the entry instead of replacing it would renumber prompts 21-95
|
| 1019 |
+
# and silently break world_p44 (owl), world_p60 (waterfall) and world_p82
|
| 1020 |
+
# (court), which are referenced by index everywhere in FINDINGS and by
|
| 1021 |
+
# `demo.py --prompt-idx`. Substituting the text keeps every index where it was.
|
| 1022 |
+
#
|
| 1023 |
+
# Consequence to act on: the six teacher clips already generated for prompt 20
|
| 1024 |
+
# (`data/teacher/p0020_s0*.pt`) were made from the old text and must be deleted
|
| 1025 |
+
# so `gen_teacher.py` regenerates them. Nothing else is affected.
|
| 1026 |
+
REPLACEMENTS = {
|
| 1027 |
+
20: "A woman blowing bubbles in a garden, sunlight through the bubbles, summer afternoon",
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
# Words that would indicate a minor in a prompt. Checked against the FINAL bank,
|
| 1031 |
+
# so it covers the original 96 as well as the extension -- a convention that is
|
| 1032 |
+
# only written down in a docstring is one nobody notices breaking.
|
| 1033 |
+
_MINOR_TERMS = (
|
| 1034 |
+
'child', 'children', 'kid', 'kids', 'boy', 'girl', 'toddler', 'baby',
|
| 1035 |
+
'babies', 'infant', 'teen', 'teenage', 'teenager', 'youngster', 'schoolboy',
|
| 1036 |
+
'schoolgirl', 'schoolchild', 'pupil', 'playground', 'nursery', 'daughter',
|
| 1037 |
+
'son', 'juvenile', 'minor', 'adolescent', 'youth', 'newborn', 'grandchild',
|
| 1038 |
+
)
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
def assert_no_minors(prompts):
|
| 1042 |
+
"""Raise if any prompt reads as depicting a minor. Word-boundary matched, so
|
| 1043 |
+
'person' does not trip 'son' and 'stonemason' does not trip it either."""
|
| 1044 |
+
import re
|
| 1045 |
+
pat = re.compile(r'\b(' + '|'.join(_MINOR_TERMS) + r')\b', re.I)
|
| 1046 |
+
bad = [(i, p) for i, p in enumerate(prompts) if pat.search(p)]
|
| 1047 |
+
if bad:
|
| 1048 |
+
raise AssertionError(
|
| 1049 |
+
f'{len(bad)} prompt(s) reference a minor; this bank must contain '
|
| 1050 |
+
f'none. First: index {bad[0][0]} -- {bad[0][1]!r}')
|
| 1051 |
+
return True
|
wanstreamer/rope.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Precomputed 3D RoPE with a temporal offset.
|
| 2 |
+
|
| 3 |
+
Two problems with upstream `wan.modules.model.rope_apply`:
|
| 4 |
+
|
| 5 |
+
1. It reconstructs its frequency tensor on EVERY call -- a float64 cat/expand/
|
| 6 |
+
reshape over the whole sequence -- and the streaming path calls it twice per
|
| 7 |
+
block, 30 blocks per latent frame (60x per frame-forward). The profile in
|
| 8 |
+
PROGRESS.md §6 attributes a large share of the 55 ms frame-forward to this
|
| 9 |
+
kind of churn.
|
| 10 |
+
2. It has no temporal offset, so the streaming path (which patch-embeds one
|
| 11 |
+
latent frame at a time, giving grid f=1) always indexes freqs[0][:1] --
|
| 12 |
+
temporal position 0 for every frame. Measured in diag/rope_probe.py: using
|
| 13 |
+
the true absolute frame index instead cuts normalised flow error by up to
|
| 14 |
+
3.2x, because the pretrained backbone still expects real temporal RoPE.
|
| 15 |
+
|
| 16 |
+
This module precomputes the per-frame frequency table once per (resolution,
|
| 17 |
+
max_frames) and reduces application to one complex multiply.
|
| 18 |
+
"""
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RopeTable:
|
| 23 |
+
"""Per-temporal-index RoPE frequency tables for a fixed spatial grid.
|
| 24 |
+
|
| 25 |
+
freqs: the model's [1024, c] complex buffer (WanModel.freqs), c = head_dim/2.
|
| 26 |
+
Table t holds the flattened (1, h, w) grid at temporal position t, shaped
|
| 27 |
+
[h*w, 1, c] so it broadcasts over batch and heads.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(self, freqs, h, w, max_frames, device, dtype=torch.complex64):
|
| 31 |
+
c = freqs.shape[1]
|
| 32 |
+
# upstream split: temporal gets the remainder, height and width get c//3 each
|
| 33 |
+
f_t, f_h, f_w = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1)
|
| 34 |
+
assert max_frames <= f_t.shape[0], (
|
| 35 |
+
f'max_frames={max_frames} exceeds rope_params table ({f_t.shape[0]})')
|
| 36 |
+
assert h <= f_h.shape[0] and w <= f_w.shape[0], (
|
| 37 |
+
f'grid {h}x{w} exceeds rope_params table '
|
| 38 |
+
f'({f_h.shape[0]}x{f_w.shape[0]}) -- raise rope_params(1024, ...)')
|
| 39 |
+
|
| 40 |
+
spatial = torch.cat([
|
| 41 |
+
f_h[:h].view(h, 1, -1).expand(h, w, -1),
|
| 42 |
+
f_w[:w].view(1, w, -1).expand(h, w, -1),
|
| 43 |
+
], dim=-1).reshape(h * w, -1) # [S, c_h + c_w]
|
| 44 |
+
|
| 45 |
+
tables = []
|
| 46 |
+
for t in range(max_frames):
|
| 47 |
+
temporal = f_t[t].view(1, -1).expand(h * w, -1) # [S, c_t]
|
| 48 |
+
tables.append(torch.cat([temporal, spatial], dim=-1))
|
| 49 |
+
# [max_frames, S, 1, c]
|
| 50 |
+
self.table = torch.stack(tables).unsqueeze(2).to(device=device, dtype=dtype)
|
| 51 |
+
self.h, self.w, self.seq = h, w, h * w
|
| 52 |
+
self.max_frames = max_frames
|
| 53 |
+
|
| 54 |
+
def frame(self, t_index):
|
| 55 |
+
"""[S, 1, c] complex table for a single latent frame at temporal index t."""
|
| 56 |
+
if t_index >= self.max_frames:
|
| 57 |
+
raise IndexError(f'temporal index {t_index} >= max_frames {self.max_frames}')
|
| 58 |
+
return self.table[t_index]
|
| 59 |
+
|
| 60 |
+
def span(self, t_start, num_frames):
|
| 61 |
+
"""[num_frames*S, 1, c] for a contiguous run of latent frames."""
|
| 62 |
+
end = t_start + num_frames
|
| 63 |
+
if end > self.max_frames:
|
| 64 |
+
raise IndexError(f'span [{t_start},{end}) exceeds max_frames {self.max_frames}')
|
| 65 |
+
return self.table[t_start:end].reshape(num_frames * self.seq, 1, -1)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def apply_rope(x, table):
|
| 69 |
+
"""x: [B, L, n, d] real -> [B, L, n, d] real, rotated by `table` [L, 1, c].
|
| 70 |
+
|
| 71 |
+
Done in float32 complex rather than upstream's float64. Verified equivalent
|
| 72 |
+
within bf16 tolerance by tests/test_streaming_core.py.
|
| 73 |
+
"""
|
| 74 |
+
b, l, n, d = x.shape
|
| 75 |
+
xc = torch.view_as_complex(x.float().reshape(b, l, n, d // 2, 2))
|
| 76 |
+
out = torch.view_as_real(xc * table.unsqueeze(0))
|
| 77 |
+
return out.flatten(3).to(x.dtype) if x.dtype != torch.float32 else out.flatten(3)
|
wanstreamer/serve/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Browser demo layer: a serving shell around the project's inference core.
|
| 2 |
+
|
| 3 |
+
Everything that touches the streaming maths lives in `wanstreamer` proper
|
| 4 |
+
(`stream.FewStepStreamer`, `blockcausal`, `kvcache`, `rope`). This package only
|
| 5 |
+
drives it: session management, JPEG framing, a websocket, world generation from
|
| 6 |
+
text, and the UI.
|
| 7 |
+
"""
|
wanstreamer/serve/auth.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared token gate for the demo server.
|
| 2 |
+
|
| 3 |
+
Starlette's `BaseHTTPMiddleware` never sees websocket scopes, and the frame stream is
|
| 4 |
+
a websocket, so this is a plain ASGI wrapper rather than a FastAPI middleware.
|
| 5 |
+
|
| 6 |
+
A token supplied in the query string is echoed back as a cookie, so visiting
|
| 7 |
+
`/?token=...` once is enough: the websocket handshake then carries it automatically
|
| 8 |
+
and the URL can be shared without the query string surviving in every request.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from urllib.parse import parse_qs
|
| 12 |
+
|
| 13 |
+
COOKIE = "livewan_token"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TokenAuth:
|
| 17 |
+
def __init__(self, app, token):
|
| 18 |
+
self.app, self.token = app, token
|
| 19 |
+
|
| 20 |
+
def _supplied(self, scope):
|
| 21 |
+
headers = {k.lower(): v for k, v in (scope.get("headers") or [])}
|
| 22 |
+
token = parse_qs(scope.get("query_string", b"").decode()).get("token", [None])[0]
|
| 23 |
+
if token:
|
| 24 |
+
return token
|
| 25 |
+
auth = headers.get(b"authorization", b"").decode()
|
| 26 |
+
if auth.startswith("Bearer "):
|
| 27 |
+
return auth[7:]
|
| 28 |
+
for part in headers.get(b"cookie", b"").decode().split(";"):
|
| 29 |
+
k, _, v = part.strip().partition("=")
|
| 30 |
+
if k == COOKIE:
|
| 31 |
+
return v
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
async def __call__(self, scope, receive, send):
|
| 35 |
+
if not self.token or scope["type"] not in ("http", "websocket"):
|
| 36 |
+
return await self.app(scope, receive, send)
|
| 37 |
+
|
| 38 |
+
if self._supplied(scope) != self.token:
|
| 39 |
+
if scope["type"] == "websocket":
|
| 40 |
+
return await send({"type": "websocket.close", "code": 1008})
|
| 41 |
+
await send({"type": "http.response.start", "status": 401,
|
| 42 |
+
"headers": [(b"content-type", b"text/plain; charset=utf-8")]})
|
| 43 |
+
return await send({"type": "http.response.body",
|
| 44 |
+
"body": b"unauthorized -- append ?token=<token> to the URL"})
|
| 45 |
+
|
| 46 |
+
if scope["type"] == "http":
|
| 47 |
+
async def _send(msg):
|
| 48 |
+
if msg["type"] == "http.response.start":
|
| 49 |
+
msg["headers"] = list(msg.get("headers") or []) + [
|
| 50 |
+
(b"set-cookie",
|
| 51 |
+
f"{COOKIE}={self.token}; Path=/; SameSite=Lax; Max-Age=604800".encode())
|
| 52 |
+
]
|
| 53 |
+
await send(msg)
|
| 54 |
+
|
| 55 |
+
return await self.app(scope, receive, _send)
|
| 56 |
+
await self.app(scope, receive, send)
|
wanstreamer/serve/conditioning.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt conditioning for the demo: the 96 prompt bank, plus free text encoding.
|
| 2 |
+
|
| 3 |
+
Two sources of conditioning, deliberately kept distinct:
|
| 4 |
+
|
| 5 |
+
* **The bank** (`data/prompts.pt`) -- 96 umt5-xxl embeddings that every clip in the
|
| 6 |
+
project was generated and trained under. Zero cost, and exactly the conditioning
|
| 7 |
+
the published numbers refer to.
|
| 8 |
+
* **Free text** -- encoded here, on this machine, with umt5-xxl (11.4 GB, loaded
|
| 9 |
+
lazily on first use). Note that umt5 embeddings are mildly hardware-dependent, so
|
| 10 |
+
text encoded here is not numerically identical to what the same string would give
|
| 11 |
+
on the training box. It looks fine; it just isn't the *same* conditioning, so
|
| 12 |
+
free-text results are not strictly comparable to the bank's published metrics.
|
| 13 |
+
|
| 14 |
+
Both paths end in the same shape: [1, 512, 4096], zero padded, which is what
|
| 15 |
+
`WanModel` expects.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
TEXT_LEN = 512
|
| 23 |
+
|
| 24 |
+
THEMES = [
|
| 25 |
+
("People", 0, 40),
|
| 26 |
+
("Animals", 40, 56),
|
| 27 |
+
("Nature", 56, 72),
|
| 28 |
+
("City", 72, 86),
|
| 29 |
+
("Objects", 86, 96),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def theme_of(idx):
|
| 34 |
+
for name, lo, hi in THEMES:
|
| 35 |
+
if lo <= idx < hi:
|
| 36 |
+
return name
|
| 37 |
+
return "Other"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class PromptBank:
|
| 41 |
+
def __init__(self, path):
|
| 42 |
+
d = torch.load(path, map_location="cpu", weights_only=False)
|
| 43 |
+
self.texts = d["prompts"]
|
| 44 |
+
self.pos = d["pos"] # [96, 512, 4096] fp16
|
| 45 |
+
self.neg = d["neg"]
|
| 46 |
+
self.neg_prompt = d["neg_prompt"]
|
| 47 |
+
|
| 48 |
+
def __len__(self):
|
| 49 |
+
return len(self.texts)
|
| 50 |
+
|
| 51 |
+
def embedding(self, idx):
|
| 52 |
+
return self.pos[idx : idx + 1].float()
|
| 53 |
+
|
| 54 |
+
def catalogue(self):
|
| 55 |
+
return [
|
| 56 |
+
{"idx": i, "text": t, "theme": theme_of(i)} for i, t in enumerate(self.texts)
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TextEncoder:
|
| 61 |
+
"""Lazy umt5-xxl. 11.4 GB -- only loaded if someone actually types a prompt."""
|
| 62 |
+
|
| 63 |
+
def __init__(self, checkpoint, tokenizer_path, wan_repo, device="cuda"):
|
| 64 |
+
self.checkpoint = str(checkpoint)
|
| 65 |
+
self.tokenizer_path = str(tokenizer_path)
|
| 66 |
+
self.wan_repo = str(wan_repo)
|
| 67 |
+
self.device = device
|
| 68 |
+
self._model = None
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def loaded(self):
|
| 72 |
+
return self._model is not None
|
| 73 |
+
|
| 74 |
+
def load(self):
|
| 75 |
+
if self._model is not None:
|
| 76 |
+
return
|
| 77 |
+
from wan.modules.t5 import T5EncoderModel
|
| 78 |
+
|
| 79 |
+
self._model = T5EncoderModel(
|
| 80 |
+
text_len=TEXT_LEN, dtype=torch.bfloat16, device=self.device,
|
| 81 |
+
checkpoint_path=self.checkpoint, tokenizer_path=self.tokenizer_path)
|
| 82 |
+
|
| 83 |
+
@torch.no_grad()
|
| 84 |
+
def encode(self, text):
|
| 85 |
+
"""-> [1, 512, 4096] float32, zero-padded exactly as the bank is."""
|
| 86 |
+
self.load()
|
| 87 |
+
ctx = self._model([text], self.device)[0] # [L, 4096], L <= 512
|
| 88 |
+
out = torch.zeros(TEXT_LEN, ctx.shape[1], dtype=torch.float32, device=ctx.device)
|
| 89 |
+
out[: ctx.shape[0]] = ctx.float()
|
| 90 |
+
return out.unsqueeze(0).cpu()
|
| 91 |
+
|
| 92 |
+
def unload(self):
|
| 93 |
+
self._model = None
|
| 94 |
+
torch.cuda.empty_cache()
|
wanstreamer/serve/engine.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session engine for the browser demo.
|
| 2 |
+
|
| 3 |
+
This is a thin serving layer over the project's own inference core: the student is
|
| 4 |
+
rolled out by `wanstreamer.stream.FewStepStreamer`, which is the code the checkpoint
|
| 5 |
+
was distilled and measured under. Nothing here reimplements the streaming maths --
|
| 6 |
+
the block loop, the block-causal K/V cache, the renoise sampler and `latent_norm`
|
| 7 |
+
all live in the core, and this module only drives them and turns latents into JPEG.
|
| 8 |
+
|
| 9 |
+
One GPU, one stream. A background worker generates blocks and pushes frames into a
|
| 10 |
+
bounded queue. The queue is deliberately short (~1.5 s) because it *is* the steering
|
| 11 |
+
latency (anything a viewer has already been sent cannot be steered any more).
|
| 12 |
+
|
| 13 |
+
Two controls, doing genuinely different things:
|
| 14 |
+
|
| 15 |
+
steer -- swap the cross-attention conditioning, keep the K/V cache. The scene
|
| 16 |
+
continues. Cheap, instant, no reload.
|
| 17 |
+
scene -- tear the stream down and reopen from another world. A cut.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import queue
|
| 22 |
+
import threading
|
| 23 |
+
import time
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
from ..stream import FewStepStreamer
|
| 30 |
+
from .paths import WORLDS_DIR
|
| 31 |
+
from .streamdecode import StreamingVAEDecoder
|
| 32 |
+
from .conditioning import PromptBank, TextEncoder
|
| 33 |
+
from .worldgen import WorldGenerator
|
| 34 |
+
|
| 35 |
+
WORLDS = [0, 44, 60, 82] # the four shipped caches; generated ones get ids from 1000
|
| 36 |
+
GENERATED_BASE_ID = 1000
|
| 37 |
+
FPS = 16
|
| 38 |
+
|
| 39 |
+
# Defaults matching the published command in the model card. `shift=1.0` is the
|
| 40 |
+
# uniform few-step spacing the student was distilled under (see
|
| 41 |
+
# wanstreamer.stream.few_step_schedule); `window` is in LATENT FRAMES, not blocks.
|
| 42 |
+
DEFAULTS = dict(block=3, steps=2, window=6, latent_norm=1.0, shift=1.0,
|
| 43 |
+
sampler="renoise")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _encode_jpeg(arr, quality=88):
|
| 47 |
+
import cv2
|
| 48 |
+
|
| 49 |
+
ok, buf = cv2.imencode(".jpg", arr[:, :, ::-1], [cv2.IMWRITE_JPEG_QUALITY, quality])
|
| 50 |
+
if not ok:
|
| 51 |
+
raise RuntimeError("jpeg encode failed")
|
| 52 |
+
return buf.tobytes()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class StreamExhausted(RuntimeError):
|
| 56 |
+
pass
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class Status:
|
| 61 |
+
state: str = "idle" # idle | loading | generating | streaming | error
|
| 62 |
+
detail: str = ""
|
| 63 |
+
world: int | None = None
|
| 64 |
+
prompt: str = ""
|
| 65 |
+
prompt_source: str = "" # "bank" | "text"
|
| 66 |
+
stats: dict = field(default_factory=dict)
|
| 67 |
+
error: str = ""
|
| 68 |
+
progress: float | None = None # 0..1 while generating a world
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class Engine:
|
| 72 |
+
def __init__(self, assets, wan_repo, base_dir, weights=None, device="cuda",
|
| 73 |
+
jpeg_quality=88, buffer_seconds=1.5, worlds_dir=None,
|
| 74 |
+
allow_worldgen=True, size=(640, 368), compile_vae=True):
|
| 75 |
+
self.assets = Path(assets)
|
| 76 |
+
self.wan_repo = Path(wan_repo)
|
| 77 |
+
self.base_dir = Path(base_dir)
|
| 78 |
+
self.weights = Path(weights or self.assets / "checkpoints/t14b_b64/latest.pt")
|
| 79 |
+
self.worlds_dir = Path(worlds_dir or WORLDS_DIR)
|
| 80 |
+
self.allow_worldgen = allow_worldgen
|
| 81 |
+
self.size = size
|
| 82 |
+
self.device = device
|
| 83 |
+
self.jpeg_quality = jpeg_quality
|
| 84 |
+
self.compile_vae = compile_vae
|
| 85 |
+
self.maxframes = int(buffer_seconds * FPS)
|
| 86 |
+
|
| 87 |
+
self.worlds = {}
|
| 88 |
+
self.worldgen = None
|
| 89 |
+
self.status = Status()
|
| 90 |
+
self.lock = threading.Lock()
|
| 91 |
+
self._genlock = threading.Lock()
|
| 92 |
+
self.frames = queue.Queue(maxsize=self.maxframes)
|
| 93 |
+
self._worker = None
|
| 94 |
+
self._stop = threading.Event()
|
| 95 |
+
self._pending_prompt = None
|
| 96 |
+
|
| 97 |
+
self.model = self.decoder = self.streamer = None
|
| 98 |
+
self._cur_emb = None
|
| 99 |
+
self.bank = self.encoder = None
|
| 100 |
+
self.step = None
|
| 101 |
+
self.cfg = None
|
| 102 |
+
self._loaded = False
|
| 103 |
+
self._timings = {}
|
| 104 |
+
self._frames_emitted = 0
|
| 105 |
+
self._blocks = 0
|
| 106 |
+
|
| 107 |
+
# ------------------------------------------------------------------ load
|
| 108 |
+
|
| 109 |
+
def load(self, progress=None):
|
| 110 |
+
def say(msg):
|
| 111 |
+
self.status.state, self.status.detail = "loading", msg
|
| 112 |
+
if progress:
|
| 113 |
+
progress(msg)
|
| 114 |
+
|
| 115 |
+
say("reading the prompt bank")
|
| 116 |
+
self.bank = PromptBank(self.assets / "data/prompts.pt")
|
| 117 |
+
|
| 118 |
+
say(f"loading the student ({self.weights.name})")
|
| 119 |
+
self.model, self.cfg, self.step = self._load_student()
|
| 120 |
+
|
| 121 |
+
say("loading the Wan2.1 VAE")
|
| 122 |
+
torch.backends.cudnn.benchmark = True
|
| 123 |
+
self.decoder = StreamingVAEDecoder(
|
| 124 |
+
self.base_dir / "Wan2.1_VAE.pth", self.wan_repo, self.device
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
if self.compile_vae:
|
| 128 |
+
say("compiling the decoder (one-off, ~40 s)")
|
| 129 |
+
try:
|
| 130 |
+
import torch._dynamo as dynamo
|
| 131 |
+
|
| 132 |
+
dynamo.config.recompile_limit = 64
|
| 133 |
+
self.decoder.model.decoder = torch.compile(
|
| 134 |
+
self.decoder.model.decoder, dynamic=False)
|
| 135 |
+
w = torch.load(self.assets / "out/world_p60.pt", map_location="cpu",
|
| 136 |
+
weights_only=False)
|
| 137 |
+
self.decoder.decode(w["latents"][:, :6].to(self.device))
|
| 138 |
+
self.decoder.reset()
|
| 139 |
+
except Exception as e: # an optimisation, never a requirement
|
| 140 |
+
self.status.detail = f"decoder compile skipped: {e}"
|
| 141 |
+
|
| 142 |
+
tok = self.base_dir / "umt5-tokenizer"
|
| 143 |
+
self.encoder = TextEncoder(
|
| 144 |
+
self.base_dir / "models_t5_umt5-xxl-enc-bf16.pth",
|
| 145 |
+
tok if tok.exists() else "google/umt5-xxl", self.wan_repo, self.device,
|
| 146 |
+
)
|
| 147 |
+
if self.allow_worldgen:
|
| 148 |
+
self.worldgen = WorldGenerator(
|
| 149 |
+
self.base_dir / "diffusion_pytorch_model.safetensors",
|
| 150 |
+
self.cfg, self.device,
|
| 151 |
+
)
|
| 152 |
+
self._index_worlds()
|
| 153 |
+
self._loaded = True
|
| 154 |
+
self.status.state, self.status.detail = "idle", "ready"
|
| 155 |
+
|
| 156 |
+
def _load_student(self):
|
| 157 |
+
"""Build the stock WanModel and load the distilled weights into it.
|
| 158 |
+
|
| 159 |
+
Mirrors scripts/demo.py: a checkpoint that silently half-loaded would report
|
| 160 |
+
base-model quality as if it were trained, so every parameter is checked.
|
| 161 |
+
"""
|
| 162 |
+
from wan.configs import WAN_CONFIGS
|
| 163 |
+
from wan.modules.model import WanModel
|
| 164 |
+
from safetensors.torch import load_file
|
| 165 |
+
|
| 166 |
+
cfg = WAN_CONFIGS["t2v-1.3B"]
|
| 167 |
+
m = WanModel(dim=cfg.dim, ffn_dim=cfg.ffn_dim, freq_dim=cfg.freq_dim,
|
| 168 |
+
num_heads=cfg.num_heads, num_layers=cfg.num_layers,
|
| 169 |
+
window_size=cfg.window_size, qk_norm=True,
|
| 170 |
+
cross_attn_norm=True, eps=1e-6)
|
| 171 |
+
m.load_state_dict(
|
| 172 |
+
load_file(str(self.base_dir / "diffusion_pytorch_model.safetensors")),
|
| 173 |
+
strict=True)
|
| 174 |
+
sd = torch.load(self.weights, map_location="cpu", weights_only=False)
|
| 175 |
+
src = sd.get("model", sd)
|
| 176 |
+
res = m.load_state_dict(src, strict=False)
|
| 177 |
+
got = {n for n, _ in m.named_parameters()} - set(res.missing_keys)
|
| 178 |
+
if len(got) != len(list(m.named_parameters())) or res.unexpected_keys:
|
| 179 |
+
raise RuntimeError(
|
| 180 |
+
f"bad checkpoint load: {len(res.missing_keys)} missing, "
|
| 181 |
+
f"{len(res.unexpected_keys)} unexpected")
|
| 182 |
+
return m.to(self.device).eval().requires_grad_(False), cfg, sd.get("step")
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------- worlds
|
| 185 |
+
|
| 186 |
+
def _index_worlds(self):
|
| 187 |
+
self.worlds = {
|
| 188 |
+
w: {"path": self.assets / f"out/world_p{w}.pt", "prompt": self.bank.texts[w],
|
| 189 |
+
"generated": False, "seconds": None}
|
| 190 |
+
for w in WORLDS
|
| 191 |
+
}
|
| 192 |
+
self.worlds_dir.mkdir(parents=True, exist_ok=True)
|
| 193 |
+
for meta_path in sorted(self.worlds_dir.glob("world_*.json")):
|
| 194 |
+
try:
|
| 195 |
+
meta = json.loads(meta_path.read_text())
|
| 196 |
+
pt = meta_path.with_suffix(".pt")
|
| 197 |
+
if pt.exists():
|
| 198 |
+
self.worlds[int(meta["id"])] = {
|
| 199 |
+
"path": pt, "prompt": meta.get("prompt", ""),
|
| 200 |
+
"generated": True, "seconds": meta.get("seconds")}
|
| 201 |
+
except Exception:
|
| 202 |
+
continue # a half-written world should not stop the server booting
|
| 203 |
+
|
| 204 |
+
def _next_world_id(self):
|
| 205 |
+
used = [i for i in self.worlds if i >= GENERATED_BASE_ID]
|
| 206 |
+
return max(used) + 1 if used else GENERATED_BASE_ID
|
| 207 |
+
|
| 208 |
+
@property
|
| 209 |
+
def worldgen_available(self):
|
| 210 |
+
return bool(self.worldgen and self.worldgen.available)
|
| 211 |
+
|
| 212 |
+
def generate_world(self, text=None, idx=None, steps=30, seed=0, guide=5.0):
|
| 213 |
+
"""Make a new opening world from text (or a bank prompt) with the base model.
|
| 214 |
+
|
| 215 |
+
Slow (~52 s at 30 steps) and the only non-real-time step in the project.
|
| 216 |
+
"""
|
| 217 |
+
if not self.worldgen_available:
|
| 218 |
+
raise RuntimeError(
|
| 219 |
+
"world generation is unavailable — the Wan2.1 base transformer "
|
| 220 |
+
"(diffusion_pytorch_model.safetensors) is not present")
|
| 221 |
+
if not self._genlock.acquire(blocking=False):
|
| 222 |
+
raise RuntimeError("already generating a world")
|
| 223 |
+
try:
|
| 224 |
+
self._stop_worker()
|
| 225 |
+
pos, label, source = self.resolve_prompt(idx, text)
|
| 226 |
+
self.status = Status(state="generating", detail="encoding the prompt",
|
| 227 |
+
prompt=label, prompt_source=source, progress=0.0)
|
| 228 |
+
|
| 229 |
+
def on_step(i, n):
|
| 230 |
+
self.status.progress = i / n
|
| 231 |
+
self.status.detail = f"denoising the world — step {i} of {n}"
|
| 232 |
+
|
| 233 |
+
lat, secs = self.worldgen.generate(
|
| 234 |
+
pos, self.bank.neg.float(), size=self.size, steps=steps, seed=seed,
|
| 235 |
+
guide=guide, progress=on_step)
|
| 236 |
+
|
| 237 |
+
self.status.detail, self.status.progress = "decoding", 1.0
|
| 238 |
+
self.decoder.reset()
|
| 239 |
+
pixels = self.decoder.decode(lat)
|
| 240 |
+
self.decoder.reset()
|
| 241 |
+
|
| 242 |
+
wid = self._next_world_id()
|
| 243 |
+
path = self.worlds_dir / f"world_{wid}.pt"
|
| 244 |
+
# keep the conditioning with the world: reopening needs no text encoder
|
| 245 |
+
torch.save({"latents": lat.cpu(), "pixels": pixels,
|
| 246 |
+
"prompt_emb": pos.cpu(), "base_seconds": secs}, path)
|
| 247 |
+
path.with_suffix(".json").write_text(json.dumps(
|
| 248 |
+
{"id": wid, "prompt": label, "steps": steps, "seed": seed,
|
| 249 |
+
"guide": guide, "seconds": round(secs, 1)}, indent=1))
|
| 250 |
+
self.worlds[wid] = {"path": path, "prompt": label, "generated": True,
|
| 251 |
+
"seconds": round(secs, 1)}
|
| 252 |
+
self.status = Status(state="idle", detail="world ready", prompt=label,
|
| 253 |
+
prompt_source=source)
|
| 254 |
+
return wid
|
| 255 |
+
except Exception as e:
|
| 256 |
+
self.status = Status(state="error", error=f"{type(e).__name__}: {e}")
|
| 257 |
+
raise
|
| 258 |
+
finally:
|
| 259 |
+
self._genlock.release()
|
| 260 |
+
|
| 261 |
+
def delete_world(self, wid):
|
| 262 |
+
w = self.worlds.get(int(wid))
|
| 263 |
+
if not w or not w["generated"]:
|
| 264 |
+
raise ValueError("only generated worlds can be deleted")
|
| 265 |
+
Path(w["path"]).unlink(missing_ok=True)
|
| 266 |
+
Path(w["path"]).with_suffix(".json").unlink(missing_ok=True)
|
| 267 |
+
self.worlds.pop(int(wid), None)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def resolve_prompt(self, idx=None, text=None):
|
| 272 |
+
"""-> (embedding [1, 512, 4096], label, source)"""
|
| 273 |
+
if text:
|
| 274 |
+
text = text.strip()
|
| 275 |
+
if not text:
|
| 276 |
+
raise ValueError("empty prompt")
|
| 277 |
+
return self.encoder.encode(text), text, "text"
|
| 278 |
+
if idx is None:
|
| 279 |
+
raise ValueError("need a prompt index or text")
|
| 280 |
+
idx = int(idx)
|
| 281 |
+
if not 0 <= idx < len(self.bank):
|
| 282 |
+
extra = ""
|
| 283 |
+
if idx >= GENERATED_BASE_ID:
|
| 284 |
+
extra = (f" — {idx} looks like a generated world id, not a prompt. "
|
| 285 |
+
"Generated worlds carry their own conditioning: pass no "
|
| 286 |
+
"prompt_idx/prompt_text and it will be used.")
|
| 287 |
+
raise ValueError(f"prompt index must be 0-{len(self.bank)-1}{extra}")
|
| 288 |
+
return self.bank.embedding(idx), self.bank.texts[idx], "bank"
|
| 289 |
+
|
| 290 |
+
def _set_text(self, emb):
|
| 291 |
+
"""Push raw umt5 conditioning into the streamer, embedded as the core wants."""
|
| 292 |
+
emb = emb.to(self.device)
|
| 293 |
+
with torch.amp.autocast("cuda", enabled=False):
|
| 294 |
+
ctx = self.model.text_embedding(emb.float())
|
| 295 |
+
self.streamer.set_text(ctx)
|
| 296 |
+
self._cur_emb = emb # kept so a crossfade can interpolate from it
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def start(self, world, idx=None, text=None, seed=0, steps=None, block=None,
|
| 301 |
+
window=None, latent_norm=None, shift=None, sampler=None):
|
| 302 |
+
if not self._loaded:
|
| 303 |
+
raise RuntimeError("engine not loaded")
|
| 304 |
+
world = int(world)
|
| 305 |
+
entry = self.worlds.get(world)
|
| 306 |
+
if entry is None:
|
| 307 |
+
raise ValueError(f"unknown world {world}; have {sorted(self.worlds)}")
|
| 308 |
+
|
| 309 |
+
block = block or DEFAULTS["block"]
|
| 310 |
+
steps = steps or DEFAULTS["steps"]
|
| 311 |
+
window = window or DEFAULTS["window"]
|
| 312 |
+
shift = DEFAULTS["shift"] if shift is None else shift
|
| 313 |
+
sampler = sampler or DEFAULTS["sampler"]
|
| 314 |
+
latent_norm = DEFAULTS["latent_norm"] if latent_norm is None else latent_norm
|
| 315 |
+
|
| 316 |
+
with self.lock:
|
| 317 |
+
self._stop_worker()
|
| 318 |
+
self.status = Status(state="loading", detail="opening the world", world=world)
|
| 319 |
+
w = torch.load(entry["path"], map_location="cpu", weights_only=False)
|
| 320 |
+
|
| 321 |
+
emb = label = source = None
|
| 322 |
+
if idx is None and text is None:
|
| 323 |
+
if not entry["generated"]:
|
| 324 |
+
idx = world
|
| 325 |
+
elif w.get("prompt_emb") is not None:
|
| 326 |
+
emb, label, source = w["prompt_emb"].float(), entry["prompt"], "text"
|
| 327 |
+
else:
|
| 328 |
+
text = entry["prompt"]
|
| 329 |
+
if emb is None:
|
| 330 |
+
emb, label, source = self.resolve_prompt(idx, text)
|
| 331 |
+
self.status.prompt, self.status.prompt_source = label, source
|
| 332 |
+
|
| 333 |
+
world_lat = w["latents"]
|
| 334 |
+
nw = world_lat.shape[1]
|
| 335 |
+
self.streamer = None
|
| 336 |
+
torch.cuda.empty_cache()
|
| 337 |
+
self.streamer = FewStepStreamer(
|
| 338 |
+
self.model, width=self.size[0], height=self.size[1],
|
| 339 |
+
max_frames=1024, device=self.device, dtype=self.cfg.param_dtype,
|
| 340 |
+
window_frames=window,
|
| 341 |
+
# the world's K/V is pinned; the window bounds only the events after it
|
| 342 |
+
cache_frames=nw + window + block + 2,
|
| 343 |
+
block_frames=block, num_steps=steps, shift=shift, sampler=sampler,
|
| 344 |
+
)
|
| 345 |
+
self.streamer.latent_norm = latent_norm
|
| 346 |
+
self._set_text(emb)
|
| 347 |
+
|
| 348 |
+
self.decoder.reset()
|
| 349 |
+
self._frames_emitted = self._blocks = 0
|
| 350 |
+
self._pending_prompt = None
|
| 351 |
+
self._gen = torch.Generator(device=self.device).manual_seed(int(seed))
|
| 352 |
+
|
| 353 |
+
t0 = time.time()
|
| 354 |
+
self.streamer.set_world(world_lat.to(self.device, torch.float32))
|
| 355 |
+
pixels = self.decoder.decode(world_lat.to(self.device, torch.float32))
|
| 356 |
+
self._timings = {"start_s": round(time.time() - t0, 3)}
|
| 357 |
+
self._frames_emitted = pixels.shape[0]
|
| 358 |
+
|
| 359 |
+
self._stop.clear()
|
| 360 |
+
self._worker = threading.Thread(target=self._run, args=(pixels,),
|
| 361 |
+
daemon=True, name="livewan-worker")
|
| 362 |
+
self._worker.start()
|
| 363 |
+
self.status.state, self.status.detail = "streaming", ""
|
| 364 |
+
return self.status
|
| 365 |
+
|
| 366 |
+
def steer(self, idx=None, text=None, crossfade=0):
|
| 367 |
+
if self.streamer is None or self.status.state != "streaming":
|
| 368 |
+
raise RuntimeError("no stream is running")
|
| 369 |
+
emb, label, source = self.resolve_prompt(idx, text)
|
| 370 |
+
self._pending_prompt = (emb, int(crossfade))
|
| 371 |
+
self.status.prompt, self.status.prompt_source = label, source
|
| 372 |
+
return self.status
|
| 373 |
+
|
| 374 |
+
def stop(self):
|
| 375 |
+
with self.lock:
|
| 376 |
+
self._stop_worker()
|
| 377 |
+
self.status = Status(state="idle", detail="stopped")
|
| 378 |
+
|
| 379 |
+
def _stop_worker(self):
|
| 380 |
+
self._stop.set()
|
| 381 |
+
if self._worker and self._worker.is_alive():
|
| 382 |
+
self._worker.join(timeout=15)
|
| 383 |
+
self._worker = None
|
| 384 |
+
self._drain()
|
| 385 |
+
|
| 386 |
+
def _drain(self):
|
| 387 |
+
try:
|
| 388 |
+
while True:
|
| 389 |
+
self.frames.get_nowait()
|
| 390 |
+
except queue.Empty:
|
| 391 |
+
pass
|
| 392 |
+
|
| 393 |
+
def _push(self, pixels):
|
| 394 |
+
arr = pixels.numpy()
|
| 395 |
+
for i in range(arr.shape[0]):
|
| 396 |
+
data = _encode_jpeg(arr[i], self.jpeg_quality)
|
| 397 |
+
while not self._stop.is_set():
|
| 398 |
+
try:
|
| 399 |
+
self.frames.put(data, timeout=0.25)
|
| 400 |
+
break
|
| 401 |
+
except queue.Full:
|
| 402 |
+
continue
|
| 403 |
+
if self._stop.is_set():
|
| 404 |
+
return
|
| 405 |
+
|
| 406 |
+
def _run(self, world_pixels):
|
| 407 |
+
blend = None
|
| 408 |
+
try:
|
| 409 |
+
self._push(world_pixels)
|
| 410 |
+
while not self._stop.is_set():
|
| 411 |
+
if self._pending_prompt is not None:
|
| 412 |
+
emb, xf = self._pending_prompt
|
| 413 |
+
self._pending_prompt = None
|
| 414 |
+
if xf and self._cur_emb is not None:
|
| 415 |
+
blend = [self._cur_emb.clone(), emb.to(self.device), 0, xf]
|
| 416 |
+
else:
|
| 417 |
+
blend = None
|
| 418 |
+
self._set_text(emb)
|
| 419 |
+
st = self.streamer
|
| 420 |
+
if st.n_world + st.n_frames + st.block_frames > st.max_frames:
|
| 421 |
+
raise StreamExhausted(
|
| 422 |
+
f"stream reached the {st.max_frames}-latent-frame RoPE "
|
| 423 |
+
"ceiling; start a new stream")
|
| 424 |
+
|
| 425 |
+
t0 = time.time()
|
| 426 |
+
z = st.generate_block(generator=self._gen)
|
| 427 |
+
t_gen = time.time()
|
| 428 |
+
pixels = self.decoder.decode(z[0].float())
|
| 429 |
+
self._blocks += 1
|
| 430 |
+
self._frames_emitted += pixels.shape[0]
|
| 431 |
+
self._timings = {
|
| 432 |
+
"gen_s": round(t_gen - t0, 4),
|
| 433 |
+
"decode_s": round(time.time() - t_gen, 4),
|
| 434 |
+
"total_s": round(time.time() - t0, 4),
|
| 435 |
+
}
|
| 436 |
+
self.status.stats = self.stats()
|
| 437 |
+
|
| 438 |
+
if blend:
|
| 439 |
+
src, dst, i, n = blend
|
| 440 |
+
i += 1
|
| 441 |
+
if i >= n:
|
| 442 |
+
blend = None
|
| 443 |
+
self._set_text(dst)
|
| 444 |
+
else:
|
| 445 |
+
blend[2] = i
|
| 446 |
+
self._set_text(src * (1 - i / n) + dst * (i / n))
|
| 447 |
+
self._push(pixels)
|
| 448 |
+
except StreamExhausted as e:
|
| 449 |
+
self.status.state, self.status.detail = "idle", str(e)
|
| 450 |
+
except Exception as e: # surface, never die silently
|
| 451 |
+
self.status.state, self.status.error = "error", f"{type(e).__name__}: {e}"
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def stats(self):
|
| 456 |
+
st = self.streamer
|
| 457 |
+
return {
|
| 458 |
+
"frames": self._frames_emitted,
|
| 459 |
+
"blocks": self._blocks,
|
| 460 |
+
"latent_frames": (st.n_world + st.n_frames) if st else 0,
|
| 461 |
+
"latent_frames_max": st.max_frames if st else 0,
|
| 462 |
+
"seconds": self._frames_emitted / FPS,
|
| 463 |
+
"kv_mb": (st.cache.memory_bytes() / 1e6) if st else 0.0,
|
| 464 |
+
**self._timings,
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
def info(self):
|
| 468 |
+
return {
|
| 469 |
+
"step": self.step,
|
| 470 |
+
"weights": self.weights.name,
|
| 471 |
+
"worlds": [
|
| 472 |
+
{"idx": i, "prompt": w["prompt"], "generated": w["generated"],
|
| 473 |
+
"seconds": 81 / FPS, "gen_seconds": w["seconds"]}
|
| 474 |
+
for i, w in sorted(self.worlds.items())
|
| 475 |
+
],
|
| 476 |
+
"prompts": self.bank.catalogue(),
|
| 477 |
+
"fps": FPS,
|
| 478 |
+
"encoder_loaded": self.encoder.loaded if self.encoder else False,
|
| 479 |
+
"worldgen": self.worldgen_available,
|
| 480 |
+
"buffer_frames": self.maxframes,
|
| 481 |
+
"defaults": DEFAULTS,
|
| 482 |
+
}
|
wanstreamer/serve/paths.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Where the weights live.
|
| 2 |
+
|
| 3 |
+
`setup.sh` downloads into the repository directory, so the defaults resolve there
|
| 4 |
+
too and a fresh clone runs without arguments. Each can be overridden by an
|
| 5 |
+
environment variable, or by the matching command-line flag.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _p(env, *default):
|
| 15 |
+
return str(Path(os.environ.get(env) or ROOT.joinpath(*default)))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
ASSETS = _p("LIVEWAN_ASSETS", "assets") # the LiveWan HF repo
|
| 19 |
+
BASE_DIR = _p("LIVEWAN_BASE_DIR", "wan21_13b") # VAE, umt5, base transformer
|
| 20 |
+
WAN_REPO = _p("LIVEWAN_WAN_REPO", "wan21_repo") # the Wan2.1 reference code
|
| 21 |
+
WORLDS_DIR = _p("LIVEWAN_WORLDS_DIR", "generated_worlds")
|
| 22 |
+
WEIGHTS = str(Path(ASSETS) / "checkpoints/t14b_b64/latest.pt")
|
wanstreamer/serve/server.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LiveWan browser demo server.
|
| 2 |
+
|
| 3 |
+
livewan-serve --port 17070 # or: python -m wanstreamer.serve.server
|
| 4 |
+
|
| 5 |
+
Serves a single steerable stream: pick a world (or generate a new one from text),
|
| 6 |
+
then steer it with the 96-prompt bank or free text while it runs.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import asyncio
|
| 11 |
+
import queue
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from contextlib import asynccontextmanager
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
import uvicorn
|
| 19 |
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 20 |
+
from fastapi.responses import HTMLResponse, Response
|
| 21 |
+
from pydantic import BaseModel
|
| 22 |
+
|
| 23 |
+
from . import paths
|
| 24 |
+
from .auth import TokenAuth
|
| 25 |
+
from .conditioning import theme_of
|
| 26 |
+
from .engine import WORLDS, Engine
|
| 27 |
+
import cv2
|
| 28 |
+
|
| 29 |
+
INDEX = Path(__file__).resolve().parent / "web" / "index.html"
|
| 30 |
+
|
| 31 |
+
# Populated by main(); the endpoints read it rather than module-level globals built at
|
| 32 |
+
# import time, so importing this module has no side effects.
|
| 33 |
+
_S = {"engine": None, "boot": {"messages": [], "done": False, "error": None},
|
| 34 |
+
"thumbs": {}, "clients": set()}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def E() -> Engine:
|
| 38 |
+
return _S["engine"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def build_parser():
|
| 42 |
+
p = argparse.ArgumentParser(prog="livewan-serve", description=__doc__,
|
| 43 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 44 |
+
p.add_argument("--assets", default=paths.ASSETS, help="the LiveWan weights/data dir")
|
| 45 |
+
p.add_argument("--wan-repo", default=paths.WAN_REPO, help="Wan2.1 reference checkout")
|
| 46 |
+
p.add_argument("--base-dir", default=paths.BASE_DIR, help="VAE / umt5 / base model")
|
| 47 |
+
p.add_argument("--weights", default=None, help="defaults to checkpoints/t14b_b64/latest.pt")
|
| 48 |
+
p.add_argument("--worlds-dir", default=paths.WORLDS_DIR, help="where generated worlds go")
|
| 49 |
+
p.add_argument("--host", default="127.0.0.1")
|
| 50 |
+
p.add_argument("--port", type=int, default=17070)
|
| 51 |
+
p.add_argument("--jpeg-quality", type=int, default=85)
|
| 52 |
+
p.add_argument("--buffer-seconds", type=float, default=1.5)
|
| 53 |
+
p.add_argument("--no-compile", action="store_true",
|
| 54 |
+
help="skip the one-off VAE decoder compile (~50 ms/block slower)")
|
| 55 |
+
p.add_argument("--no-worldgen", action="store_true",
|
| 56 |
+
help="disable text->world generation (saves 2.6 GB VRAM)")
|
| 57 |
+
p.add_argument("--token", default="1234",
|
| 58 |
+
help="shared token for ?token=/Bearer/cookie auth; empty string disables")
|
| 59 |
+
return p
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class StartReq(BaseModel):
|
| 64 |
+
world: int
|
| 65 |
+
prompt_idx: int | None = None
|
| 66 |
+
prompt_text: str | None = None
|
| 67 |
+
seed: int = 0
|
| 68 |
+
steps: int = 2
|
| 69 |
+
window: int = 6
|
| 70 |
+
latent_norm: float = 1.0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class SteerReq(BaseModel):
|
| 74 |
+
prompt_idx: int | None = None
|
| 75 |
+
prompt_text: str | None = None
|
| 76 |
+
crossfade: int = 0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class WorldReq(BaseModel):
|
| 80 |
+
prompt_text: str | None = None
|
| 81 |
+
prompt_idx: int | None = None
|
| 82 |
+
steps: int = 30
|
| 83 |
+
seed: int = 0
|
| 84 |
+
guide: float = 5.0
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _make_thumb(wid):
|
| 89 |
+
"""One representative frame per world, cached in memory for the picker."""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
entry = E().worlds.get(int(wid))
|
| 93 |
+
if not entry:
|
| 94 |
+
return
|
| 95 |
+
d = torch.load(entry["path"], map_location="cpu", weights_only=False)
|
| 96 |
+
frame = d["pixels"][40].numpy()[:, :, ::-1]
|
| 97 |
+
_S["thumbs"][int(wid)] = cv2.imencode(
|
| 98 |
+
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 82])[1].tobytes()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _boot_load():
|
| 102 |
+
try:
|
| 103 |
+
E().load(progress=lambda m: _S["boot"]["messages"].append(m))
|
| 104 |
+
for w in list(E().worlds):
|
| 105 |
+
_make_thumb(w)
|
| 106 |
+
_S["boot"]["done"] = True
|
| 107 |
+
except Exception as e:
|
| 108 |
+
_S["boot"]["error"] = f"{type(e).__name__}: {e}"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@asynccontextmanager
|
| 112 |
+
async def _lifespan(_app):
|
| 113 |
+
task = asyncio.create_task(_pump())
|
| 114 |
+
yield
|
| 115 |
+
task.cancel()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
app = FastAPI(title="LiveWan", lifespan=_lifespan)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@app.get("/", response_class=HTMLResponse)
|
| 123 |
+
def index():
|
| 124 |
+
return INDEX.read_text()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@app.get("/api/boot")
|
| 128 |
+
def boot():
|
| 129 |
+
b = _S["boot"]
|
| 130 |
+
return {"done": b["done"], "error": b["error"], "messages": b["messages"][-6:],
|
| 131 |
+
"detail": E().status.detail if E() else ""}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.get("/api/info")
|
| 135 |
+
def info():
|
| 136 |
+
if not _S["boot"]["done"]:
|
| 137 |
+
raise HTTPException(503, "still loading")
|
| 138 |
+
d = E().info()
|
| 139 |
+
d["world_themes"] = {w: theme_of(w) for w in WORLDS}
|
| 140 |
+
return d
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.get("/api/status")
|
| 144 |
+
def status():
|
| 145 |
+
e = E()
|
| 146 |
+
s = e.status
|
| 147 |
+
return {
|
| 148 |
+
"state": s.state, "detail": s.detail, "world": s.world,
|
| 149 |
+
"prompt": s.prompt, "prompt_source": s.prompt_source,
|
| 150 |
+
"stats": s.stats, "error": s.error, "progress": s.progress,
|
| 151 |
+
"buffered": e.frames.qsize(),
|
| 152 |
+
"encoder_loaded": e.encoder.loaded if e.encoder else False,
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@app.get("/api/thumb/{world}")
|
| 157 |
+
def thumb(world: int):
|
| 158 |
+
if world not in _S["thumbs"]:
|
| 159 |
+
raise HTTPException(404, "no thumbnail")
|
| 160 |
+
return Response(_S["thumbs"][world], media_type="image/jpeg")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.post("/api/start")
|
| 164 |
+
def start(r: StartReq):
|
| 165 |
+
if not _S["boot"]["done"]:
|
| 166 |
+
raise HTTPException(503, "still loading")
|
| 167 |
+
try:
|
| 168 |
+
E().start(r.world, r.prompt_idx, r.prompt_text, seed=r.seed, steps=r.steps,
|
| 169 |
+
window=r.window, latent_norm=r.latent_norm)
|
| 170 |
+
except Exception as e:
|
| 171 |
+
raise HTTPException(400, f"{type(e).__name__}: {e}")
|
| 172 |
+
return status()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@app.post("/api/steer")
|
| 176 |
+
def steer(r: SteerReq):
|
| 177 |
+
try:
|
| 178 |
+
E().steer(r.prompt_idx, r.prompt_text, crossfade=r.crossfade)
|
| 179 |
+
except Exception as e:
|
| 180 |
+
raise HTTPException(400, f"{type(e).__name__}: {e}")
|
| 181 |
+
return status()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@app.post("/api/world/new")
|
| 185 |
+
def world_new(r: WorldReq):
|
| 186 |
+
"""Generate a brand-new opening world from text. Slow: ~52 s at 30 steps."""
|
| 187 |
+
if not _S["boot"]["done"]:
|
| 188 |
+
raise HTTPException(503, "still loading")
|
| 189 |
+
try:
|
| 190 |
+
wid = E().generate_world(r.prompt_text, r.prompt_idx, steps=r.steps,
|
| 191 |
+
seed=r.seed, guide=r.guide)
|
| 192 |
+
_make_thumb(wid)
|
| 193 |
+
except Exception as e:
|
| 194 |
+
raise HTTPException(400, f"{type(e).__name__}: {e}")
|
| 195 |
+
return {"world": wid, **E().info()}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@app.delete("/api/world/{wid}")
|
| 199 |
+
def world_delete(wid: int):
|
| 200 |
+
try:
|
| 201 |
+
E().delete_world(wid)
|
| 202 |
+
_S["thumbs"].pop(wid, None)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
raise HTTPException(400, f"{type(e).__name__}: {e}")
|
| 205 |
+
return E().info()
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@app.post("/api/stop")
|
| 209 |
+
def stop():
|
| 210 |
+
E().stop()
|
| 211 |
+
return status()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _next_frame(timeout=0.1):
|
| 216 |
+
try:
|
| 217 |
+
return E().frames.get(True, timeout)
|
| 218 |
+
except queue.Empty:
|
| 219 |
+
return None
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def _pump():
|
| 223 |
+
"""Single reader of the frame queue, broadcasting to every viewer.
|
| 224 |
+
|
| 225 |
+
One consumer only. If each socket drained the queue itself, two viewers would
|
| 226 |
+
each get half the frames.
|
| 227 |
+
"""
|
| 228 |
+
loop = asyncio.get_running_loop()
|
| 229 |
+
clients = _S["clients"]
|
| 230 |
+
last = 0.0
|
| 231 |
+
while True:
|
| 232 |
+
if not clients or E() is None:
|
| 233 |
+
await asyncio.sleep(0.1)
|
| 234 |
+
continue
|
| 235 |
+
frame = await loop.run_in_executor(None, _next_frame, 0.1)
|
| 236 |
+
dead = []
|
| 237 |
+
if frame is not None:
|
| 238 |
+
for c in list(clients):
|
| 239 |
+
try:
|
| 240 |
+
await c.send_bytes(frame)
|
| 241 |
+
except Exception:
|
| 242 |
+
dead.append(c)
|
| 243 |
+
if time.time() - last > 0.5:
|
| 244 |
+
last = time.time()
|
| 245 |
+
payload = {"t": "status", **status()}
|
| 246 |
+
for c in list(clients):
|
| 247 |
+
try:
|
| 248 |
+
await c.send_json(payload)
|
| 249 |
+
except Exception:
|
| 250 |
+
dead.append(c)
|
| 251 |
+
for c in dead:
|
| 252 |
+
clients.discard(c)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@app.websocket("/ws")
|
| 256 |
+
async def ws(sock: WebSocket):
|
| 257 |
+
await sock.accept()
|
| 258 |
+
_S["clients"].add(sock)
|
| 259 |
+
try:
|
| 260 |
+
await sock.send_json({"t": "status", **status()})
|
| 261 |
+
while True:
|
| 262 |
+
await sock.receive_text() # client keepalive; ignored
|
| 263 |
+
except (WebSocketDisconnect, RuntimeError):
|
| 264 |
+
pass
|
| 265 |
+
finally:
|
| 266 |
+
_S["clients"].discard(sock)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def main(argv=None):
|
| 271 |
+
args = build_parser().parse_args(argv)
|
| 272 |
+
_S["engine"] = Engine(
|
| 273 |
+
args.assets, args.wan_repo, args.base_dir, args.weights,
|
| 274 |
+
jpeg_quality=args.jpeg_quality, buffer_seconds=args.buffer_seconds,
|
| 275 |
+
worlds_dir=args.worlds_dir, allow_worldgen=not args.no_worldgen,
|
| 276 |
+
compile_vae=not args.no_compile,
|
| 277 |
+
)
|
| 278 |
+
threading.Thread(target=_boot_load, daemon=True, name="livewan-boot").start()
|
| 279 |
+
|
| 280 |
+
served = TokenAuth(app, args.token) if args.token else app
|
| 281 |
+
if args.token:
|
| 282 |
+
print(f"token auth on; open http://{args.host}:{args.port}/?token={args.token}")
|
| 283 |
+
else:
|
| 284 |
+
print(f"no auth; open http://{args.host}:{args.port}/")
|
| 285 |
+
uvicorn.run(served, host=args.host, port=args.port, log_level="warning",
|
| 286 |
+
ws_max_size=16 * 2**20)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
main()
|
wanstreamer/serve/streamdecode.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming wrapper around the Wan2.1 VAE decoder.
|
| 2 |
+
|
| 3 |
+
The offline path (`scripts/demo.py`) decodes the whole latent sequence in one call at
|
| 4 |
+
the end of a run. A live stream cannot: it has to emit pixels every block. Stock
|
| 5 |
+
`WanVAE_.decode` clears its causal-conv feature cache on entry and exit, so calling it
|
| 6 |
+
once per block would restart the temporal convolutions and seam every 3 latent frames.
|
| 7 |
+
The decoder is already causal and already walks the sequence one latent frame at a
|
| 8 |
+
time ( the only thing between it and a continuous stream is that `clear_cache()`).
|
| 9 |
+
This keeps the cache alive across calls instead.
|
| 10 |
+
|
| 11 |
+
Frame arithmetic: the first latent frame of a stream decodes to 1 pixel frame, every
|
| 12 |
+
later one to 4 (temporal stride 4). So a 21-frame world -> 81 pixel frames, and each
|
| 13 |
+
subsequent 3-frame block -> 12 pixel frames = 750 ms at 16 fps.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class StreamingVAEDecoder:
|
| 20 |
+
def __init__(self, vae_path, wan_repo=None, device="cuda", dtype=torch.float16):
|
| 21 |
+
from wan.modules.vae import WanVAE
|
| 22 |
+
|
| 23 |
+
self.wrapper = WanVAE(vae_pth=str(vae_path), dtype=dtype, device=device)
|
| 24 |
+
self.model = self.wrapper.model
|
| 25 |
+
self.device, self.dtype = device, dtype
|
| 26 |
+
self.scale = self.wrapper.scale
|
| 27 |
+
self.reset()
|
| 28 |
+
|
| 29 |
+
def reset(self):
|
| 30 |
+
self.model.clear_cache()
|
| 31 |
+
|
| 32 |
+
@torch.no_grad()
|
| 33 |
+
def decode(self, z):
|
| 34 |
+
"""z: [C, F, H, W] latents -> uint8 pixels [T, H*8, W*8, 3] (RGB).
|
| 35 |
+
|
| 36 |
+
Continues the previous call's temporal context; call `reset()` to cut.
|
| 37 |
+
"""
|
| 38 |
+
z = z.to(self.device, torch.float32).unsqueeze(0).clamp(-4, 4)
|
| 39 |
+
mean, inv_std = self.scale
|
| 40 |
+
z = z / inv_std.view(1, -1, 1, 1, 1).float() + mean.view(1, -1, 1, 1, 1).float()
|
| 41 |
+
|
| 42 |
+
with torch.amp.autocast("cuda", dtype=self.dtype):
|
| 43 |
+
x = self.model.conv2(z)
|
| 44 |
+
outs = []
|
| 45 |
+
for i in range(x.shape[2]):
|
| 46 |
+
self.model._conv_idx = [0]
|
| 47 |
+
outs.append(self.model.decoder(
|
| 48 |
+
x[:, :, i:i + 1], feat_cache=self.model._feat_map,
|
| 49 |
+
feat_idx=self.model._conv_idx))
|
| 50 |
+
out = torch.cat(outs, dim=2)
|
| 51 |
+
|
| 52 |
+
out = out.float().clamp_(-1, 1).squeeze(0) # [3, T, H, W]
|
| 53 |
+
out = ((out.permute(1, 2, 3, 0) + 1) * 127.5).clamp(0, 255).to(torch.uint8)
|
| 54 |
+
return out.cpu()
|
| 55 |
+
|
| 56 |
+
def memory_bytes(self):
|
| 57 |
+
return sum(t.numel() * t.element_size()
|
| 58 |
+
for t in (self.model._feat_map or []) if torch.is_tensor(t))
|
wanstreamer/serve/web/index.html
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 6 |
+
<title>LiveWan — steerable streaming video</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0c0d10; --panel:#14161b; --panel2:#1b1e25; --line:#282c36;
|
| 10 |
+
--ink:#e8eaf0; --dim:#9aa1b1; --faint:#666d7e;
|
| 11 |
+
--accent:#5b8cff; --accent2:#37d39b; --warn:#f2b544; --bad:#ef5f6b;
|
| 12 |
+
--radius:12px;
|
| 13 |
+
}
|
| 14 |
+
*{box-sizing:border-box}
|
| 15 |
+
body{margin:0;background:var(--bg);color:var(--ink);
|
| 16 |
+
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,sans-serif}
|
| 17 |
+
button,input,select{font:inherit;color:inherit}
|
| 18 |
+
.wrap{max-width:1320px;margin:0 auto;padding:20px}
|
| 19 |
+
|
| 20 |
+
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:18px}
|
| 21 |
+
h1{font-size:20px;margin:0;letter-spacing:-.01em}
|
| 22 |
+
h1 span{color:var(--accent)}
|
| 23 |
+
.sub{color:var(--dim);font-size:13px}
|
| 24 |
+
.pill{margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--panel);
|
| 25 |
+
border:1px solid var(--line);border-radius:999px;padding:5px 12px;font-size:12px;color:var(--dim)}
|
| 26 |
+
.dot{width:8px;height:8px;border-radius:50%;background:var(--faint)}
|
| 27 |
+
.dot.live{background:var(--accent2);box-shadow:0 0 0 3px rgba(55,211,155,.16)}
|
| 28 |
+
.dot.load{background:var(--warn);animation:pulse 1s infinite}
|
| 29 |
+
.dot.err{background:var(--bad)}
|
| 30 |
+
@keyframes pulse{50%{opacity:.35}}
|
| 31 |
+
|
| 32 |
+
.grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:18px}
|
| 33 |
+
@media(max-width:1040px){.grid{grid-template-columns:1fr}}
|
| 34 |
+
|
| 35 |
+
.stage{background:#000;border:1px solid var(--line);border-radius:var(--radius);
|
| 36 |
+
position:relative;overflow:hidden;aspect-ratio:640/368}
|
| 37 |
+
.stage canvas{width:100%;height:100%;display:block}
|
| 38 |
+
.overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
|
| 39 |
+
justify-content:center;gap:0;background:rgba(8,9,12,.96);backdrop-filter:blur(6px);
|
| 40 |
+
text-align:center;padding:24px;z-index:10}
|
| 41 |
+
.overlay.hidden{display:none}
|
| 42 |
+
.overlay:before{content:"";position:absolute;inset:0;
|
| 43 |
+
background:radial-gradient(circle at 50% 42%,rgba(91,140,255,.14),transparent 62%)}
|
| 44 |
+
.spinner{width:46px;height:46px;border:3px solid rgba(255,255,255,.09);
|
| 45 |
+
border-top-color:var(--accent);border-radius:50%;animation:spin .75s linear infinite;
|
| 46 |
+
margin-bottom:20px;position:relative}
|
| 47 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 48 |
+
.ov-title{font-size:20px;font-weight:650;letter-spacing:-.01em;position:relative}
|
| 49 |
+
.ov-detail{color:var(--dim);font-size:13.5px;max-width:460px;margin-top:8px;position:relative}
|
| 50 |
+
.ov-log{color:var(--faint);font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
| 51 |
+
margin-top:14px;position:relative}
|
| 52 |
+
/* staged progress: shows which step of a multi-stage load is running */
|
| 53 |
+
.ov-steps{display:flex;gap:0;margin-top:20px;position:relative;align-items:center}
|
| 54 |
+
.ov-step{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--faint);
|
| 55 |
+
padding:0 4px}
|
| 56 |
+
.ov-step i{width:7px;height:7px;border-radius:50%;background:var(--line);flex:none;
|
| 57 |
+
transition:background .25s,box-shadow .25s}
|
| 58 |
+
.ov-step.done{color:var(--dim)}
|
| 59 |
+
.ov-step.done i{background:var(--accent2)}
|
| 60 |
+
.ov-step.now{color:var(--ink);font-weight:600}
|
| 61 |
+
.ov-step.now i{background:var(--accent);box-shadow:0 0 0 4px rgba(91,140,255,.2);
|
| 62 |
+
animation:pulse 1.1s infinite}
|
| 63 |
+
.ov-sep{width:22px;height:1px;background:var(--line)}
|
| 64 |
+
.ov-bar{width:min(330px,72%);height:3px;border-radius:2px;background:rgba(255,255,255,.08);
|
| 65 |
+
margin-top:20px;overflow:hidden;position:relative}
|
| 66 |
+
.ov-bar i{display:block;height:100%;border-radius:2px;background:var(--accent);
|
| 67 |
+
width:30%;animation:slide 1.5s ease-in-out infinite}
|
| 68 |
+
@keyframes slide{0%{transform:translateX(-110%)}100%{transform:translateX(370%)}}
|
| 69 |
+
.ov-bar.hide{display:none}
|
| 70 |
+
.ov-hint{margin-top:16px;font-size:11.5px;color:var(--faint);max-width:420px;
|
| 71 |
+
position:relative;line-height:1.6}
|
| 72 |
+
|
| 73 |
+
.bar{display:flex;gap:14px;flex-wrap:wrap;margin-top:10px;padding:10px 13px;
|
| 74 |
+
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);font-size:12px}
|
| 75 |
+
.bar div{display:flex;gap:6px}
|
| 76 |
+
.bar b{font-weight:600;font-variant-numeric:tabular-nums}
|
| 77 |
+
.bar span{color:var(--faint)}
|
| 78 |
+
|
| 79 |
+
.now{margin-top:10px;padding:12px 14px;background:var(--panel);border:1px solid var(--line);
|
| 80 |
+
border-radius:var(--radius)}
|
| 81 |
+
.now .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint)}
|
| 82 |
+
.now .txt{margin-top:3px;font-size:13.5px}
|
| 83 |
+
.tag{display:inline-block;font-size:10.5px;padding:1px 7px;border-radius:999px;
|
| 84 |
+
border:1px solid var(--line);color:var(--dim);margin-left:7px;vertical-align:1px}
|
| 85 |
+
|
| 86 |
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
| 87 |
+
padding:14px;margin-bottom:14px}
|
| 88 |
+
.card h2{margin:0 0 3px;font-size:13px;letter-spacing:.02em}
|
| 89 |
+
.card p.hint{margin:0 0 11px;color:var(--dim);font-size:12px}
|
| 90 |
+
|
| 91 |
+
.worlds{display:grid;grid-template-columns:1fr 1fr;gap:9px}
|
| 92 |
+
.world{position:relative;border:1.5px solid var(--line);border-radius:9px;overflow:hidden;
|
| 93 |
+
cursor:pointer;background:#000;padding:0;text-align:left;transition:border-color .15s}
|
| 94 |
+
.world:hover{border-color:var(--accent)}
|
| 95 |
+
.world.on{border-color:var(--accent2)}
|
| 96 |
+
.world img{width:100%;aspect-ratio:640/368;object-fit:cover;display:block;opacity:.85}
|
| 97 |
+
.world .cap{padding:6px 8px;font-size:11px;color:var(--dim);line-height:1.35}
|
| 98 |
+
.world .cap span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
|
| 99 |
+
overflow:hidden;height:2.7em}
|
| 100 |
+
.wbadge{position:absolute;top:5px;left:5px;font-size:9.5px;padding:2px 6px;border-radius:999px;
|
| 101 |
+
background:rgba(55,211,155,.9);color:#06281c;font-weight:650;letter-spacing:.02em}
|
| 102 |
+
.wdel{position:absolute;top:4px;right:4px;width:18px;height:18px;border-radius:50%;
|
| 103 |
+
background:rgba(0,0,0,.62);color:#fff;font-size:13px;line-height:17px;text-align:center;
|
| 104 |
+
opacity:0;transition:opacity .15s}
|
| 105 |
+
.world:hover .wdel{opacity:1}
|
| 106 |
+
.wdel:hover{background:var(--bad)}
|
| 107 |
+
|
| 108 |
+
.tabs{display:flex;gap:4px;margin-bottom:11px;background:var(--panel2);padding:3px;border-radius:9px}
|
| 109 |
+
.tabs button{flex:1;background:none;border:0;padding:6px;border-radius:7px;color:var(--dim);
|
| 110 |
+
cursor:pointer;font-size:12.5px}
|
| 111 |
+
.tabs button.on{background:var(--panel);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.4)}
|
| 112 |
+
|
| 113 |
+
input[type=text],input[type=search],input[type=number]{width:100%;background:var(--panel2);
|
| 114 |
+
border:1px solid var(--line);border-radius:8px;padding:8px 10px;outline:none}
|
| 115 |
+
input:focus{border-color:var(--accent)}
|
| 116 |
+
.row{display:flex;gap:8px;align-items:center}
|
| 117 |
+
.row+.row{margin-top:8px}
|
| 118 |
+
|
| 119 |
+
.btn{background:var(--accent);border:0;color:#fff;padding:8px 14px;border-radius:8px;
|
| 120 |
+
cursor:pointer;font-weight:600;font-size:13px;white-space:nowrap}
|
| 121 |
+
.btn:disabled{opacity:.4;cursor:not-allowed}
|
| 122 |
+
.btn.ghost{background:var(--panel2);border:1px solid var(--line);color:var(--ink);font-weight:500}
|
| 123 |
+
|
| 124 |
+
.chips{max-height:262px;overflow-y:auto;margin-top:9px;padding-right:3px}
|
| 125 |
+
.chips::-webkit-scrollbar{width:7px}
|
| 126 |
+
.chips::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px}
|
| 127 |
+
.theme{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);
|
| 128 |
+
margin:9px 0 5px;position:sticky;top:0;background:var(--panel);padding:2px 0}
|
| 129 |
+
.chip{display:block;width:100%;text-align:left;background:var(--panel2);border:1px solid transparent;
|
| 130 |
+
border-radius:7px;padding:6px 9px;margin-bottom:4px;cursor:pointer;font-size:12.2px;color:var(--dim);
|
| 131 |
+
line-height:1.4}
|
| 132 |
+
.chip:hover{border-color:var(--accent);color:var(--ink)}
|
| 133 |
+
.chip.on{border-color:var(--accent2);color:var(--ink)}
|
| 134 |
+
.chip em{font-style:normal;color:var(--faint);margin-right:6px;font-variant-numeric:tabular-nums}
|
| 135 |
+
|
| 136 |
+
details{border-top:1px solid var(--line);margin-top:11px;padding-top:10px}
|
| 137 |
+
summary{cursor:pointer;color:var(--dim);font-size:12px;list-style:none}
|
| 138 |
+
summary::-webkit-details-marker{display:none}
|
| 139 |
+
summary:before{content:"▸ ";color:var(--faint)}
|
| 140 |
+
details[open] summary:before{content:"▾ "}
|
| 141 |
+
.adv{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:9px}
|
| 142 |
+
.adv label{font-size:11.5px;color:var(--dim);display:block;margin-bottom:3px}
|
| 143 |
+
|
| 144 |
+
.pprow{display:grid;grid-template-columns:74px 1fr 42px;align-items:center;gap:9px;
|
| 145 |
+
margin-bottom:7px}
|
| 146 |
+
.pprow label{font-size:11.5px;color:var(--dim)}
|
| 147 |
+
.pprow b{font-size:11.5px;text-align:right;font-variant-numeric:tabular-nums;color:var(--dim)}
|
| 148 |
+
.pprow input[type=range]{width:100%;accent-color:var(--accent)}
|
| 149 |
+
|
| 150 |
+
.ftfrom{margin-top:11px}
|
| 151 |
+
.ftlabel{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint)}
|
| 152 |
+
.ftchips{display:flex;gap:5px;margin-top:6px;flex-wrap:wrap}
|
| 153 |
+
.ftchips button{flex:1 1 0;min-width:0;background:var(--panel2);border:1px solid var(--line);
|
| 154 |
+
border-radius:7px;padding:5px 7px;cursor:pointer;font-size:11px;color:var(--dim);
|
| 155 |
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:border-color .15s}
|
| 156 |
+
.ftchips button:hover{border-color:var(--accent);color:var(--ink)}
|
| 157 |
+
.ftchips button.on{border-color:var(--accent2);color:var(--ink);background:rgba(55,211,155,.08)}
|
| 158 |
+
|
| 159 |
+
.tip{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;
|
| 160 |
+
border-radius:50%;border:1px solid var(--line);color:var(--faint);font-size:9.5px;
|
| 161 |
+
cursor:help;margin-left:5px;vertical-align:1px;position:relative}
|
| 162 |
+
.tip:hover .tiptext{opacity:1;visibility:visible}
|
| 163 |
+
.tiptext{position:absolute;bottom:130%;left:50%;transform:translateX(-50%);width:250px;
|
| 164 |
+
background:#20242d;border:1px solid var(--line);border-radius:8px;padding:8px 10px;
|
| 165 |
+
font-size:11.5px;color:var(--ink);line-height:1.5;opacity:0;visibility:hidden;
|
| 166 |
+
transition:opacity .13s;z-index:20;text-align:left;box-shadow:0 6px 20px rgba(0,0,0,.5);
|
| 167 |
+
font-weight:400}
|
| 168 |
+
.note{margin-top:9px;padding:8px 10px;border-radius:8px;font-size:11.5px;line-height:1.5;
|
| 169 |
+
background:rgba(242,181,68,.09);border:1px solid rgba(242,181,68,.28);color:#f0cd8b}
|
| 170 |
+
.note.hidden{display:none}
|
| 171 |
+
.err{background:rgba(239,95,107,.1);border-color:rgba(239,95,107,.3);color:#f5a3aa}
|
| 172 |
+
footer{margin-top:20px;color:var(--faint);font-size:11.5px;line-height:1.7}
|
| 173 |
+
footer code{background:var(--panel);padding:1px 5px;border-radius:4px}
|
| 174 |
+
</style>
|
| 175 |
+
</head>
|
| 176 |
+
<body>
|
| 177 |
+
<!-- Post-processing runs in the browser compositor, not on the GPU that is generating
|
| 178 |
+
frames: an unsharp convolution here, brightness/contrast/saturation as CSS filter
|
| 179 |
+
functions on the canvas. Per-frame JS cost is zero, so the stream stays at 1x. -->
|
| 180 |
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
| 181 |
+
<filter id="pp-sharpen" x="-5%" y="-5%" width="110%" height="110%"
|
| 182 |
+
color-interpolation-filters="sRGB">
|
| 183 |
+
<feConvolveMatrix id="ppKernel" order="3" preserveAlpha="true" divisor="1"
|
| 184 |
+
kernelMatrix="0 0 0 0 1 0 0 0 0"/>
|
| 185 |
+
</filter>
|
| 186 |
+
</defs></svg>
|
| 187 |
+
|
| 188 |
+
<div class="wrap">
|
| 189 |
+
<header>
|
| 190 |
+
<h1>Live<span>Wan</span></h1>
|
| 191 |
+
<div class="sub">streaming text-to-video · Wan2.1-1.3B student · <span id="ckpt">step 3000</span></div>
|
| 192 |
+
<div class="pill"><i class="dot" id="dot"></i><span id="pilltext">connecting</span></div>
|
| 193 |
+
</header>
|
| 194 |
+
|
| 195 |
+
<div class="grid">
|
| 196 |
+
<div>
|
| 197 |
+
<div class="stage">
|
| 198 |
+
<canvas id="cv" width="640" height="368"></canvas>
|
| 199 |
+
<div class="overlay" id="ov">
|
| 200 |
+
<div class="spinner" id="ovSpin"></div>
|
| 201 |
+
<div class="ov-title" id="ovTitle">Loading the model</div>
|
| 202 |
+
<div class="ov-detail" id="ovDetail">This takes about 90 seconds on first start.</div>
|
| 203 |
+
<div class="ov-steps" id="ovSteps"></div>
|
| 204 |
+
<div class="ov-bar" id="ovBar"><i></i></div>
|
| 205 |
+
<div class="ov-log" id="ovLog"></div>
|
| 206 |
+
<div class="ov-hint" id="ovHint"></div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div class="bar">
|
| 211 |
+
<div><span>block</span><b id="mBlock">—</b></div>
|
| 212 |
+
<div><span>speed</span><b id="mSpeed">—</b></div>
|
| 213 |
+
<div><span>streamed</span><b id="mLen">—</b></div>
|
| 214 |
+
<div><span>latent frames</span><b id="mLat">—</b></div>
|
| 215 |
+
<div><span>K/V cache</span><b id="mKv">—</b></div>
|
| 216 |
+
<div><span>buffer</span><b id="mBuf">—</b></div>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div class="now">
|
| 220 |
+
<div class="lbl">Now conditioning on</div>
|
| 221 |
+
<div class="txt" id="nowPrompt">—<span class="tag" id="nowTag"></span></div>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<div class="note hidden" id="note"></div>
|
| 225 |
+
|
| 226 |
+
<div class="card" style="margin-top:10px">
|
| 227 |
+
<h2>What you are looking at</h2>
|
| 228 |
+
<p class="hint" style="margin:0">
|
| 229 |
+
A 1.3 B student, distilled from a Wan2.1-14B teacher, generating video
|
| 230 |
+
<em>continuously</em> instead of as a fixed clip. Every 750 ms it produces
|
| 231 |
+
another block of 12 frames conditioned on a rolling K/V cache of what it
|
| 232 |
+
already made — so the picture never restarts.
|
| 233 |
+
</p>
|
| 234 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:11px;margin-top:12px">
|
| 235 |
+
<div style="background:var(--panel2);border-radius:9px;padding:10px 11px">
|
| 236 |
+
<div style="font-size:11px;color:var(--accent2);font-weight:600;margin-bottom:3px">STEER · continues</div>
|
| 237 |
+
<div style="font-size:11.5px;color:var(--dim);line-height:1.5">
|
| 238 |
+
Swaps the text, keeps the cache. The scene morphs in place. Best between
|
| 239 |
+
related scenes.
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
<div style="background:var(--panel2);border-radius:9px;padding:10px 11px">
|
| 243 |
+
<div style="font-size:11px;color:var(--accent);font-weight:600;margin-bottom:3px">SCENE · cuts</div>
|
| 244 |
+
<div style="font-size:11.5px;color:var(--dim);line-height:1.5">
|
| 245 |
+
Clears the cache and reopens from a cached world. Use it when you want a
|
| 246 |
+
genuinely different picture.
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div>
|
| 254 |
+
<div class="card">
|
| 255 |
+
<h2>1 · Scene
|
| 256 |
+
<span class="tip">?<span class="tiptext">A <b>scene</b> is one of four cached worlds — 21 latent
|
| 257 |
+
frames the base model generated once, so a stream can open instantly instead of
|
| 258 |
+
waiting ~60 s. Picking one <b>cuts</b>: the stream restarts and the K/V cache is cleared.</span></span>
|
| 259 |
+
</h2>
|
| 260 |
+
<p class="hint">Pick a starting world. This cuts to a new stream.</p>
|
| 261 |
+
<div class="worlds" id="worlds"></div>
|
| 262 |
+
|
| 263 |
+
<details id="genBox" style="display:none">
|
| 264 |
+
<summary>New scene from text</summary>
|
| 265 |
+
<p class="hint" style="margin:9px 0">
|
| 266 |
+
Generates a brand-new world with the Wan2.1 base model — the same way the four
|
| 267 |
+
above were made. This is the one slow step in the project: about a minute, not
|
| 268 |
+
real time. It is then streamable forever like any other scene.
|
| 269 |
+
</p>
|
| 270 |
+
<input type="text" id="genText" placeholder="A lighthouse on a cliff in a storm…">
|
| 271 |
+
<div class="row" style="margin-top:8px">
|
| 272 |
+
<label style="font-size:11.5px;color:var(--dim)">Steps
|
| 273 |
+
<span class="tip">?<span class="tiptext">Base-model denoising steps. 30 takes
|
| 274 |
+
~52 s and looks good; 50 takes ~87 s for a little more detail.</span></span>
|
| 275 |
+
</label>
|
| 276 |
+
<select id="genSteps" style="background:var(--panel2);border:1px solid var(--line);
|
| 277 |
+
border-radius:8px;padding:6px 8px">
|
| 278 |
+
<option value="20">20 · ~35 s</option>
|
| 279 |
+
<option value="30" selected>30 · ~52 s</option>
|
| 280 |
+
<option value="50">50 · ~87 s</option>
|
| 281 |
+
</select>
|
| 282 |
+
<input type="number" id="genSeed" value="0" title="seed" style="width:74px">
|
| 283 |
+
<button class="btn" id="btnGen" style="flex:1">Generate</button>
|
| 284 |
+
</div>
|
| 285 |
+
</details>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
<div class="card">
|
| 289 |
+
<h2>2 · Steer
|
| 290 |
+
<span class="tip">?<span class="tiptext">Steering swaps the text conditioning but <b>keeps</b> the
|
| 291 |
+
K/V cache, so the scene carries on and morphs instead of cutting. Nearby scenes blend
|
| 292 |
+
beautifully; a violent jump (day → night) tends to smear.</span></span>
|
| 293 |
+
</h2>
|
| 294 |
+
<p class="hint">Change the text mid-stream. The scene continues.</p>
|
| 295 |
+
|
| 296 |
+
<div class="tabs">
|
| 297 |
+
<button class="on" data-tab="bank">Prompt bank</button>
|
| 298 |
+
<button data-tab="text">Free text</button>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<div id="tabBank">
|
| 302 |
+
<input type="search" id="search" placeholder="Search 96 prompts…" autocomplete="off">
|
| 303 |
+
<div class="chips" id="chips"></div>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
<div id="tabText" style="display:none">
|
| 307 |
+
<input type="text" id="freeText" placeholder="A lighthouse in a storm, waves breaking…">
|
| 308 |
+
|
| 309 |
+
<div class="ftfrom">
|
| 310 |
+
<span class="ftlabel">Opens from
|
| 311 |
+
<span class="tip">?<span class="tiptext">Every stream has to open from one of the four
|
| 312 |
+
cached worlds — they supply the first 21 latent frames. Your text conditions what
|
| 313 |
+
happens <b>next</b>, it cannot invent the opening picture, so pick the world that
|
| 314 |
+
starts closest to what you want.</span></span>
|
| 315 |
+
</span>
|
| 316 |
+
<div class="ftchips" id="ftWorlds"></div>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div class="row" style="margin-top:10px">
|
| 320 |
+
<button class="btn" id="btnFree">Steer the live stream</button>
|
| 321 |
+
<button class="btn ghost" id="btnFreeStart">Open as new scene</button>
|
| 322 |
+
</div>
|
| 323 |
+
<div class="note" style="display:block">
|
| 324 |
+
The opening frames always come from the world above — text steers from there rather than
|
| 325 |
+
creating the picture, so a prompt far from that world will not teleport.
|
| 326 |
+
Free text is encoded with umt5-xxl on this machine (11.4 GB, loaded once on first use);
|
| 327 |
+
the 96 bank prompts are the conditioning the model was trained and measured under.
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
<div class="row" style="margin-top:11px">
|
| 332 |
+
<label style="font-size:11.5px;color:var(--dim);flex:1">
|
| 333 |
+
Crossfade
|
| 334 |
+
<span class="tip">?<span class="tiptext">Blend the old and new conditioning over N blocks
|
| 335 |
+
instead of switching at once. Softens big jumps; 0 is the instant swap the model card
|
| 336 |
+
describes.</span></span>
|
| 337 |
+
<input type="range" id="xfade" min="0" max="12" value="0" style="width:100%">
|
| 338 |
+
</label>
|
| 339 |
+
<b id="xfadeVal" style="font-size:12px;width:56px;text-align:right">instant</b>
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
<details>
|
| 343 |
+
<summary>Advanced</summary>
|
| 344 |
+
<div class="adv">
|
| 345 |
+
<div><label>Denoising steps</label><input type="number" id="steps" value="2" min="1" max="8"></div>
|
| 346 |
+
<div><label>Seed</label><input type="number" id="seed" value="0"></div>
|
| 347 |
+
<div><label>K/V window (blocks)</label><input type="number" id="window" value="6" min="1" max="12"></div>
|
| 348 |
+
<div><label>Latent norm</label><input type="number" id="lnorm" value="1.0" step="0.1" min="0" max="1"></div>
|
| 349 |
+
</div>
|
| 350 |
+
<p class="hint" style="margin-top:9px">Defaults match the published command. Steps above 2 cost
|
| 351 |
+
proportionally more time without fixing hard steers; latent norm 0 lets long streams blow out.</p>
|
| 352 |
+
<button class="btn ghost" id="btnStop" style="margin-top:4px">Stop stream</button>
|
| 353 |
+
</details>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
<div class="card">
|
| 357 |
+
<h2>3 · Look
|
| 358 |
+
<span class="tip">?<span class="tiptext">Display-only post-processing, applied in
|
| 359 |
+
your browser as frames arrive. It costs the generator nothing, so the stream
|
| 360 |
+
stays at 1× — but it also does not change what the model produced. Useful
|
| 361 |
+
against the softness long streams drift into.</span></span>
|
| 362 |
+
</h2>
|
| 363 |
+
<p class="hint">Post-processing on the picture. Costs no GPU time.</p>
|
| 364 |
+
|
| 365 |
+
<div class="tabs" id="ppPresets">
|
| 366 |
+
<button class="on" data-pp="off">Off</button>
|
| 367 |
+
<button data-pp="subtle">Subtle</button>
|
| 368 |
+
<button data-pp="crisp">Crisp</button>
|
| 369 |
+
<button data-pp="punch">Punch</button>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<div id="ppSliders">
|
| 373 |
+
<div class="pprow"><label>Sharpen</label>
|
| 374 |
+
<input type="range" id="ppSharp" min="0" max="150" value="0"><b id="ppSharpV">0</b></div>
|
| 375 |
+
<div class="pprow"><label>Contrast</label>
|
| 376 |
+
<input type="range" id="ppCon" min="70" max="150" value="100"><b id="ppConV">1.00</b></div>
|
| 377 |
+
<div class="pprow"><label>Saturation</label>
|
| 378 |
+
<input type="range" id="ppSat" min="0" max="200" value="100"><b id="ppSatV">1.00</b></div>
|
| 379 |
+
<div class="pprow"><label>Brightness</label>
|
| 380 |
+
<input type="range" id="ppBri" min="70" max="130" value="100"><b id="ppBriV">1.00</b></div>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
<div class="row" style="margin-top:10px">
|
| 384 |
+
<button class="btn ghost" id="ppCompare" style="flex:1">Hold to compare</button>
|
| 385 |
+
<button class="btn ghost" id="ppReset">Reset</button>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<footer>
|
| 392 |
+
Block = 3 latent frames = 12 pixel frames = 750 ms of 640×368 at 16 fps.
|
| 393 |
+
Streams are capped at 1024 latent frames (~4.3 min) by <code>WanModel.freqs</code>.
|
| 394 |
+
Quality holds for roughly a minute; after that sharpness drifts.
|
| 395 |
+
</footer>
|
| 396 |
+
</div>
|
| 397 |
+
|
| 398 |
+
<script>
|
| 399 |
+
const $ = s => document.querySelector(s);
|
| 400 |
+
const cv = $('#cv'), ctx = cv.getContext('2d');
|
| 401 |
+
let INFO = null, state = 'boot', curWorld = null, curIdx = null, tab = 'bank';
|
| 402 |
+
|
| 403 |
+
/* ---------- frame buffer: decouples network jitter from playback ---------- */
|
| 404 |
+
const buf = [];
|
| 405 |
+
const TARGET = 1000/16;
|
| 406 |
+
let playing = false;
|
| 407 |
+
|
| 408 |
+
function pushFrame(blob){
|
| 409 |
+
createImageBitmap(blob).then(bm => {
|
| 410 |
+
buf.push(bm);
|
| 411 |
+
while (buf.length > 48) buf.shift().close(); // hard cap: never lag by >3 s
|
| 412 |
+
if (!playing) { playing = true; requestAnimationFrame(tick); }
|
| 413 |
+
}).catch(()=>{});
|
| 414 |
+
}
|
| 415 |
+
let last = 0;
|
| 416 |
+
function tick(now){
|
| 417 |
+
if (!last) last = now;
|
| 418 |
+
let step = TARGET;
|
| 419 |
+
if (buf.length > 32) step = TARGET * 0.75; // gently catch up when behind
|
| 420 |
+
if (now - last >= step) {
|
| 421 |
+
if (buf.length) {
|
| 422 |
+
const bm = buf.shift();
|
| 423 |
+
ctx.drawImage(bm, 0, 0, cv.width, cv.height);
|
| 424 |
+
bm.close();
|
| 425 |
+
last = now;
|
| 426 |
+
} else { last = now; }
|
| 427 |
+
}
|
| 428 |
+
requestAnimationFrame(tick);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/* ------------------------------- transport ------------------------------- */
|
| 432 |
+
function connect(){
|
| 433 |
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
| 434 |
+
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
| 435 |
+
ws.binaryType = 'blob';
|
| 436 |
+
ws.onmessage = ev => {
|
| 437 |
+
if (typeof ev.data === 'string') applyStatus(JSON.parse(ev.data));
|
| 438 |
+
else pushFrame(ev.data);
|
| 439 |
+
};
|
| 440 |
+
ws.onclose = () => setTimeout(connect, 1200);
|
| 441 |
+
setInterval(() => { if (ws.readyState === 1) ws.send('.'); }, 15000);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
/* --------------------------------- boot ---------------------------------- */
|
| 445 |
+
async function boot(){
|
| 446 |
+
let r;
|
| 447 |
+
try { r = await (await fetch('/api/boot')).json(); }
|
| 448 |
+
catch(e){ overlay('Cannot reach the server', String(e), {isErr:true}); return; }
|
| 449 |
+
|
| 450 |
+
if (r.error){ overlay('Failed to load', r.error, {isErr:true}); return; }
|
| 451 |
+
if (!r.done){
|
| 452 |
+
const msg = (r.messages||[]).slice(-1)[0] || '';
|
| 453 |
+
overlay('Starting up', 'Loading 2.6 GB of weights and compiling the VAE decoder. About 90 seconds on first start — it only happens once.',
|
| 454 |
+
{stages:'boot', active:msg, log:msg,
|
| 455 |
+
hint:'The four scene thumbnails appear as soon as this finishes.'});
|
| 456 |
+
setTimeout(boot, 600);
|
| 457 |
+
return;
|
| 458 |
+
}
|
| 459 |
+
INFO = await (await fetch('/api/info')).json();
|
| 460 |
+
$('#ckpt').textContent = `step ${INFO.step}`;
|
| 461 |
+
buildWorlds(); buildChips(); buildFtWorlds();
|
| 462 |
+
overlay('Pick a scene', 'Choose one of the four cached worlds on the right to open a stream.',
|
| 463 |
+
{isErr:true});
|
| 464 |
+
$('#ovSpin').style.display = 'none';
|
| 465 |
+
$('#dot').className = 'dot'; $('#pilltext').textContent = 'ready';
|
| 466 |
+
connect();
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/* The two multi-stage waits worth showing progress for. Each entry is matched
|
| 470 |
+
against the server's own progress message, so the dots track real work. */
|
| 471 |
+
const STAGES = {
|
| 472 |
+
boot: [
|
| 473 |
+
['prompt bank', /prompt bank/i],
|
| 474 |
+
['student', /student/i],
|
| 475 |
+
['VAE', /VAE/i],
|
| 476 |
+
['compile', /compil/i],
|
| 477 |
+
],
|
| 478 |
+
start: [
|
| 479 |
+
['world', /opening the world/i],
|
| 480 |
+
['K\/V cache', /priming/i],
|
| 481 |
+
['first frames', /^$/],
|
| 482 |
+
],
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
function overlay(title, detail, opts={}){
|
| 486 |
+
const {isErr=false, stages=null, active='', log='', hint='', pct=null} = opts;
|
| 487 |
+
const ov = $('#ov');
|
| 488 |
+
ov.classList.remove('hidden');
|
| 489 |
+
$('#ovTitle').textContent = title;
|
| 490 |
+
$('#ovDetail').textContent = detail || '';
|
| 491 |
+
$('#ovSpin').style.display = isErr ? 'none' : '';
|
| 492 |
+
const bar = $('#ovBar'), fill = bar.firstElementChild;
|
| 493 |
+
bar.classList.toggle('hide', isErr || (!stages && pct === null));
|
| 494 |
+
if (pct === null){ fill.style.animation = ''; fill.style.width = '30%'; }
|
| 495 |
+
else { fill.style.animation = 'none'; fill.style.width = Math.round(pct*100)+'%'; }
|
| 496 |
+
$('#ovLog').textContent = log;
|
| 497 |
+
$('#ovHint').textContent = hint;
|
| 498 |
+
|
| 499 |
+
const el = $('#ovSteps');
|
| 500 |
+
el.innerHTML = '';
|
| 501 |
+
if (!stages) return;
|
| 502 |
+
const list = STAGES[stages] || [];
|
| 503 |
+
let idx = list.findIndex(([, re]) => re.test(active));
|
| 504 |
+
if (idx < 0) idx = 0;
|
| 505 |
+
list.forEach(([label], i) => {
|
| 506 |
+
if (i) { const s = document.createElement('div'); s.className='ov-sep'; el.appendChild(s); }
|
| 507 |
+
const d = document.createElement('div');
|
| 508 |
+
d.className = 'ov-step ' + (i < idx ? 'done' : i === idx ? 'now' : '');
|
| 509 |
+
d.innerHTML = `<i></i>${label}`;
|
| 510 |
+
el.appendChild(d);
|
| 511 |
+
});
|
| 512 |
+
}
|
| 513 |
+
const hideOverlay = () => $('#ov').classList.add('hidden');
|
| 514 |
+
|
| 515 |
+
/* --------------------------------- build --------------------------------- */
|
| 516 |
+
function buildWorlds(){
|
| 517 |
+
const el = $('#worlds'); el.innerHTML = '';
|
| 518 |
+
INFO.worlds.forEach(w => {
|
| 519 |
+
const b = document.createElement('button');
|
| 520 |
+
b.className = 'world' + (w.idx === curWorld ? ' on' : '');
|
| 521 |
+
b.dataset.world = w.idx;
|
| 522 |
+
const badge = w.generated
|
| 523 |
+
? `<span class="wbadge">generated${w.gen_seconds ? ' · '+Math.round(w.gen_seconds)+'s' : ''}</span>` : '';
|
| 524 |
+
const del = w.generated ? `<span class="wdel" title="delete this world">×</span>` : '';
|
| 525 |
+
b.innerHTML = `<img src="/api/thumb/${w.idx}" alt="">${badge}${del}
|
| 526 |
+
<div class="cap"><span>${w.prompt}</span></div>`;
|
| 527 |
+
b.onclick = ev => {
|
| 528 |
+
if (ev.target.classList.contains('wdel')) { ev.stopPropagation(); delWorld(w.idx); return; }
|
| 529 |
+
startWorld(w.idx);
|
| 530 |
+
};
|
| 531 |
+
el.appendChild(b);
|
| 532 |
+
});
|
| 533 |
+
$('#genBox').style.display = INFO.worldgen ? '' : 'none';
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
async function delWorld(w){
|
| 537 |
+
if (!confirm('Delete this generated world?')) return;
|
| 538 |
+
const r = await fetch('/api/world/'+w, {method:'DELETE'});
|
| 539 |
+
if (r.ok){ INFO = {...INFO, ...(await r.json())}; if (curWorld===w) curWorld=null;
|
| 540 |
+
buildWorlds(); buildFtWorlds(); }
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
/* Which world the free-text tab opens from. Kept explicit and visible, because a
|
| 544 |
+
typed prompt cannot produce the opening frames -- a cached world always does. */
|
| 545 |
+
let ftWorld = null;
|
| 546 |
+
const WORLD_SHORT = {0:'Woman', 44:'Owl', 60:'Waterfall', 82:'Court'};
|
| 547 |
+
|
| 548 |
+
/* Short label for the picker: a hand-picked word for the four shipped worlds,
|
| 549 |
+
otherwise the first couple of meaningful words of the prompt it was made from. */
|
| 550 |
+
const STOP = new Set(['a','an','the','of','in','on','at','with','and','to']);
|
| 551 |
+
function worldLabel(w){
|
| 552 |
+
if (WORLD_SHORT[w.idx]) return WORLD_SHORT[w.idx];
|
| 553 |
+
const words = (w.prompt||'').split(/\s+/).filter(x => x && !STOP.has(x.toLowerCase()));
|
| 554 |
+
return words.slice(0,2).join(' ').replace(/[,.]$/,'') || `World ${w.idx}`;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
function setFtWorld(w){
|
| 558 |
+
ftWorld = w;
|
| 559 |
+
document.querySelectorAll('#ftWorlds button').forEach(b =>
|
| 560 |
+
b.classList.toggle('on', +b.dataset.world === w));
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
function buildFtWorlds(){
|
| 564 |
+
const el = $('#ftWorlds'); el.innerHTML = '';
|
| 565 |
+
INFO.worlds.forEach(w => {
|
| 566 |
+
const b = document.createElement('button');
|
| 567 |
+
b.dataset.world = w.idx;
|
| 568 |
+
b.textContent = worldLabel(w);
|
| 569 |
+
b.title = w.prompt;
|
| 570 |
+
b.onclick = () => setFtWorld(w.idx);
|
| 571 |
+
el.appendChild(b);
|
| 572 |
+
});
|
| 573 |
+
setFtWorld(ftWorld ?? curWorld ?? INFO.worlds[0].idx);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
function buildChips(filter=''){
|
| 577 |
+
const el = $('#chips'); el.innerHTML = '';
|
| 578 |
+
const f = filter.trim().toLowerCase();
|
| 579 |
+
let theme = null;
|
| 580 |
+
INFO.prompts.filter(p => !f || p.text.toLowerCase().includes(f)).forEach(p => {
|
| 581 |
+
if (p.theme !== theme){
|
| 582 |
+
theme = p.theme;
|
| 583 |
+
const h = document.createElement('div');
|
| 584 |
+
h.className = 'theme'; h.textContent = theme;
|
| 585 |
+
el.appendChild(h);
|
| 586 |
+
}
|
| 587 |
+
const b = document.createElement('button');
|
| 588 |
+
b.className = 'chip' + (p.idx === curIdx ? ' on' : '');
|
| 589 |
+
b.innerHTML = `<em>${p.idx}</em>${p.text}`;
|
| 590 |
+
b.onclick = () => steer({prompt_idx: p.idx}, p);
|
| 591 |
+
el.appendChild(b);
|
| 592 |
+
});
|
| 593 |
+
if (!el.children.length) el.innerHTML = '<div class="theme">no match</div>';
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
/* -------------------------------- actions -------------------------------- */
|
| 597 |
+
function advanced(){
|
| 598 |
+
return {
|
| 599 |
+
seed: +$('#seed').value || 0,
|
| 600 |
+
steps: +$('#steps').value || 2,
|
| 601 |
+
window: +$('#window').value || 6,
|
| 602 |
+
latent_norm: parseFloat($('#lnorm').value),
|
| 603 |
+
};
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
async function post(url, body){
|
| 607 |
+
const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'},
|
| 608 |
+
body: JSON.stringify(body)});
|
| 609 |
+
if (!r.ok){
|
| 610 |
+
const t = await r.text();
|
| 611 |
+
note(t.replace(/^\{"detail":"|"\}$/g,''), true);
|
| 612 |
+
throw new Error(t);
|
| 613 |
+
}
|
| 614 |
+
return r.json();
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
async function startWorld(w, text){
|
| 618 |
+
// Clicking a scene card opens it on that world's own bank prompt. If free text is
|
| 619 |
+
// sitting unused in the other tab, say so rather than silently ignoring it.
|
| 620 |
+
const pending = $('#freeText').value.trim();
|
| 621 |
+
// Only the four shipped worlds have ids that double as bank indices. A generated
|
| 622 |
+
// world's id (1000+) is not a prompt, so send no prompt at all and let the server
|
| 623 |
+
// use the embedding stored with the world.
|
| 624 |
+
const entry = (INFO?.worlds || []).find(x => x.idx === w);
|
| 625 |
+
const isGen = !!(entry && entry.generated);
|
| 626 |
+
curWorld = w; curIdx = (text || isGen) ? null : w;
|
| 627 |
+
setFtWorld(w);
|
| 628 |
+
if (!text && pending)
|
| 629 |
+
note('Opened this scene on its own bank prompt. To use the text you typed, ' +
|
| 630 |
+
'go to Free text and press "Open as new scene" (or "Steer the live stream").');
|
| 631 |
+
else note('');
|
| 632 |
+
document.querySelectorAll('.world').forEach(e =>
|
| 633 |
+
e.classList.toggle('on', +e.dataset.world === w));
|
| 634 |
+
const needsT5 = !!text && !INFO.encoder_loaded;
|
| 635 |
+
overlay('Opening the scene',
|
| 636 |
+
needsT5 ? 'Loading umt5-xxl (11.4 GB) to encode your prompt, then priming the K/V cache. First free-text prompt only.'
|
| 637 |
+
: 'Priming the K/V cache from 21 cached latent frames, then generating the first block.',
|
| 638 |
+
{stages:'start', active:'opening the world',
|
| 639 |
+
hint:'Takes about 5 seconds' + (needsT5 ? ', plus ~15 s for the text encoder.' : '.')});
|
| 640 |
+
buf.splice(0).forEach(b=>b.close());
|
| 641 |
+
const body = {world: w, ...advanced()};
|
| 642 |
+
if (text) body.prompt_text = text;
|
| 643 |
+
else if (!isGen) body.prompt_idx = w;
|
| 644 |
+
await post('/api/start', body);
|
| 645 |
+
buildChips($('#search').value);
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
async function steer(what, p){
|
| 649 |
+
if (state !== 'streaming'){
|
| 650 |
+
note('Start a scene first — steering needs a running stream.', true); return;
|
| 651 |
+
}
|
| 652 |
+
if (p){ curIdx = p.idx; buildChips($('#search').value); }
|
| 653 |
+
note('');
|
| 654 |
+
await post('/api/steer', {...what, crossfade: +$('#xfade').value});
|
| 655 |
+
const wTheme = INFO.world_themes[curWorld];
|
| 656 |
+
if (p && wTheme && p.theme !== wTheme)
|
| 657 |
+
note(`Steering from ${wTheme.toLowerCase()} to ${p.theme.toLowerCase()} is a big jump — expect the picture to smear. A crossfade softens it; cutting to a new scene stays clean.`);
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
/* --------------------------------- status -------------------------------- */
|
| 661 |
+
function applyStatus(s){
|
| 662 |
+
state = s.state;
|
| 663 |
+
const d = $('#dot');
|
| 664 |
+
d.className = 'dot ' + (s.state==='streaming' ? 'live'
|
| 665 |
+
: s.state==='loading' ? 'load'
|
| 666 |
+
: s.state==='error' ? 'err' : '');
|
| 667 |
+
$('#pilltext').textContent = s.state==='streaming' ? 'live'
|
| 668 |
+
: s.state==='error' ? 'error' : (s.detail || s.state);
|
| 669 |
+
// hold the overlay until frames are actually arriving, not merely promised
|
| 670 |
+
if (s.state === 'streaming' && buf.length) hideOverlay();
|
| 671 |
+
else if (s.state === 'streaming')
|
| 672 |
+
overlay('Opening the scene', 'Generating the first block…',
|
| 673 |
+
{stages:'start', active:''});
|
| 674 |
+
if (s.state === 'loading')
|
| 675 |
+
overlay('Opening the scene', s.detail || 'Working…',
|
| 676 |
+
{stages:'start', active:s.detail || ''});
|
| 677 |
+
if (s.state === 'generating')
|
| 678 |
+
overlay('Generating a new world',
|
| 679 |
+
s.detail || 'Running the Wan2.1 base model…',
|
| 680 |
+
{pct: s.progress ?? 0,
|
| 681 |
+
log: s.progress != null ? Math.round(s.progress*100)+'%' : '',
|
| 682 |
+
hint:'This is the one slow step — the base model, not the student. '
|
| 683 |
+
+ 'Once it exists you can stream from it forever at 1x.'});
|
| 684 |
+
if (s.state === 'error'){ overlay('Stream failed', s.error, {isErr:true}); note(s.error, true); }
|
| 685 |
+
if (s.state === 'idle' && s.detail && s.detail !== 'ready' && s.detail !== 'stopped')
|
| 686 |
+
overlay('Stream ended', s.detail, {isErr:true});
|
| 687 |
+
|
| 688 |
+
if (s.prompt){
|
| 689 |
+
$('#nowPrompt').childNodes[0].nodeValue = s.prompt;
|
| 690 |
+
$('#nowTag').textContent = s.prompt_source === 'text' ? 'free text' : 'bank';
|
| 691 |
+
}
|
| 692 |
+
const st = s.stats || {};
|
| 693 |
+
if (st.total_s){
|
| 694 |
+
$('#mBlock').textContent = Math.round(st.total_s*1000)+' ms';
|
| 695 |
+
$('#mSpeed').textContent = (0.75/st.total_s).toFixed(2)+'×';
|
| 696 |
+
}
|
| 697 |
+
if (st.seconds != null) $('#mLen').textContent = st.seconds.toFixed(1)+' s';
|
| 698 |
+
if (st.latent_frames != null)
|
| 699 |
+
$('#mLat').textContent = st.latent_frames+' / '+st.latent_frames_max;
|
| 700 |
+
if (st.kv_mb) $('#mKv').textContent = Math.round(st.kv_mb)+' MB';
|
| 701 |
+
$('#mBuf').textContent = buf.length+' fr';
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
function note(msg, isErr){
|
| 705 |
+
const n = $('#note');
|
| 706 |
+
n.className = 'note' + (msg ? '' : ' hidden') + (isErr ? ' err' : '');
|
| 707 |
+
n.textContent = msg;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
/* ---------------------------------- wire --------------------------------- */
|
| 711 |
+
$('#search').oninput = e => buildChips(e.target.value);
|
| 712 |
+
$('#xfade').oninput = e => {
|
| 713 |
+
const v = +e.target.value;
|
| 714 |
+
$('#xfadeVal').textContent = v ? v+' blocks' : 'instant';
|
| 715 |
+
};
|
| 716 |
+
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
|
| 717 |
+
tab = b.dataset.tab;
|
| 718 |
+
document.querySelectorAll('.tabs button').forEach(x => x.classList.toggle('on', x===b));
|
| 719 |
+
$('#tabBank').style.display = tab==='bank' ? '' : 'none';
|
| 720 |
+
$('#tabText').style.display = tab==='text' ? '' : 'none';
|
| 721 |
+
});
|
| 722 |
+
$('#btnFree').onclick = async () => {
|
| 723 |
+
const t = $('#freeText').value.trim();
|
| 724 |
+
if (!t) return note('Type a prompt first.', true);
|
| 725 |
+
if (!INFO.encoder_loaded) note('Loading umt5-xxl (11.4 GB) — first free-text prompt only…');
|
| 726 |
+
curIdx = null;
|
| 727 |
+
await steer({prompt_text: t});
|
| 728 |
+
INFO.encoder_loaded = true;
|
| 729 |
+
};
|
| 730 |
+
$('#btnFreeStart').onclick = async () => {
|
| 731 |
+
const t = $('#freeText').value.trim();
|
| 732 |
+
if (!t) return note('Type a prompt first.', true);
|
| 733 |
+
if (!INFO.encoder_loaded) note('Loading umt5-xxl (11.4 GB) — first free-text prompt only…');
|
| 734 |
+
await startWorld(ftWorld ?? curWorld ?? 60, t);
|
| 735 |
+
INFO.encoder_loaded = true;
|
| 736 |
+
};
|
| 737 |
+
$('#btnGen').onclick = async () => {
|
| 738 |
+
const t = $('#genText').value.trim();
|
| 739 |
+
if (!t) return note('Describe the scene you want first.', true);
|
| 740 |
+
const steps = +$('#genSteps').value, seed = +$('#genSeed').value || 0;
|
| 741 |
+
note('');
|
| 742 |
+
overlay('Generating a new world', 'Encoding the prompt…', {pct:0});
|
| 743 |
+
try {
|
| 744 |
+
const r = await post('/api/world/new', {prompt_text:t, steps, seed});
|
| 745 |
+
INFO = {...INFO, ...r};
|
| 746 |
+
buildWorlds(); buildFtWorlds();
|
| 747 |
+
await startWorld(r.world); // open the thing we just made
|
| 748 |
+
} catch(e){
|
| 749 |
+
overlay('Generation failed', String(e), {isErr:true});
|
| 750 |
+
}
|
| 751 |
+
};
|
| 752 |
+
|
| 753 |
+
$('#btnStop').onclick = async () => {
|
| 754 |
+
await post('/api/stop', {});
|
| 755 |
+
overlay('Stopped', 'Pick a scene to start again.', {isErr:true});
|
| 756 |
+
};
|
| 757 |
+
$('#freeText').addEventListener('keydown', e => { if (e.key==='Enter') $('#btnFree').click(); });
|
| 758 |
+
|
| 759 |
+
/* ----------------------------- post-processing ---------------------------- */
|
| 760 |
+
const PP_PRESETS = {
|
| 761 |
+
off: {sharp:0, con:100, sat:100, bri:100},
|
| 762 |
+
subtle: {sharp:35, con:105, sat:106, bri:100},
|
| 763 |
+
crisp: {sharp:80, con:110, sat:104, bri:100},
|
| 764 |
+
punch: {sharp:110, con:120, sat:130, bri:102},
|
| 765 |
+
};
|
| 766 |
+
let ppBypass = false;
|
| 767 |
+
|
| 768 |
+
function ppApply(){
|
| 769 |
+
const a = ppBypass ? 0 : +$('#ppSharp').value / 100;
|
| 770 |
+
const con = ppBypass ? 1 : +$('#ppCon').value / 100;
|
| 771 |
+
const sat = ppBypass ? 1 : +$('#ppSat').value / 100;
|
| 772 |
+
const bri = ppBypass ? 1 : +$('#ppBri').value / 100;
|
| 773 |
+
|
| 774 |
+
// unsharp: centre 1+4a, four-neighbour -a. Sums to 1, so overall exposure holds.
|
| 775 |
+
$('#ppKernel').setAttribute('kernelMatrix',
|
| 776 |
+
`0 ${-a} 0 ${-a} ${1 + 4*a} ${-a} 0 ${-a} 0`);
|
| 777 |
+
|
| 778 |
+
const parts = [];
|
| 779 |
+
if (a > 0) parts.push('url(#pp-sharpen)');
|
| 780 |
+
if (bri !== 1) parts.push(`brightness(${bri})`);
|
| 781 |
+
if (con !== 1) parts.push(`contrast(${con})`);
|
| 782 |
+
if (sat !== 1) parts.push(`saturate(${sat})`);
|
| 783 |
+
cv.style.filter = parts.join(' ') || 'none';
|
| 784 |
+
|
| 785 |
+
$('#ppSharpV').textContent = Math.round(a * 100);
|
| 786 |
+
$('#ppConV').textContent = con.toFixed(2);
|
| 787 |
+
$('#ppSatV').textContent = sat.toFixed(2);
|
| 788 |
+
$('#ppBriV').textContent = bri.toFixed(2);
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
function ppSet(name){
|
| 792 |
+
const p = PP_PRESETS[name]; if (!p) return;
|
| 793 |
+
$('#ppSharp').value = p.sharp; $('#ppCon').value = p.con;
|
| 794 |
+
$('#ppSat').value = p.sat; $('#ppBri').value = p.bri;
|
| 795 |
+
ppApply();
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
document.querySelectorAll('#ppPresets button').forEach(b => b.onclick = () => {
|
| 799 |
+
document.querySelectorAll('#ppPresets button').forEach(x => x.classList.toggle('on', x===b));
|
| 800 |
+
ppSet(b.dataset.pp);
|
| 801 |
+
});
|
| 802 |
+
['ppSharp','ppCon','ppSat','ppBri'].forEach(id => $('#'+id).oninput = () => {
|
| 803 |
+
// hand-editing a slider means no preset is exactly active any more
|
| 804 |
+
document.querySelectorAll('#ppPresets button').forEach(x => x.classList.remove('on'));
|
| 805 |
+
ppApply();
|
| 806 |
+
});
|
| 807 |
+
const ppHold = on => { ppBypass = on; ppApply(); };
|
| 808 |
+
$('#ppCompare').addEventListener('mousedown', () => ppHold(true));
|
| 809 |
+
$('#ppCompare').addEventListener('touchstart', e => { e.preventDefault(); ppHold(true); });
|
| 810 |
+
['mouseup','mouseleave','touchend','touchcancel'].forEach(ev =>
|
| 811 |
+
$('#ppCompare').addEventListener(ev, () => ppHold(false)));
|
| 812 |
+
$('#ppReset').onclick = () => {
|
| 813 |
+
document.querySelectorAll('#ppPresets button').forEach(x =>
|
| 814 |
+
x.classList.toggle('on', x.dataset.pp === 'off'));
|
| 815 |
+
ppSet('off');
|
| 816 |
+
};
|
| 817 |
+
ppApply();
|
| 818 |
+
|
| 819 |
+
boot();
|
| 820 |
+
</script>
|
| 821 |
+
</body>
|
| 822 |
+
</html>
|
wanstreamer/serve/worldgen.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate new opening worlds from text with the Wan2.1-1.3B base model.
|
| 2 |
+
|
| 3 |
+
A "world" is just the 21 latent frames a stream is primed from. The four shipped
|
| 4 |
+
ones were produced this way once and cached (`base_seconds` in each file is how long
|
| 5 |
+
it took), so nothing stops any prompt from having one.
|
| 6 |
+
|
| 7 |
+
This is the base model, not the student: full bidirectional attention over the
|
| 8 |
+
whole sequence, CFG, and a real 30-50 step schedule. It is slow by design (~52 s at
|
| 9 |
+
30 steps on an A100-40GB) and is the only part of the project that is not real time.
|
| 10 |
+
The student then extends the result indefinitely at ~1x.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import time
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
LATENT_FRAMES = 21 # -> 81 pixel frames, the shipped world length
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _sampling_sigmas(steps, shift):
|
| 22 |
+
"""Wan's own shifted inference schedule -- correct for the *undistilled* base
|
| 23 |
+
model, unlike the uniform few-step spacing the student is rolled out with."""
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
s = np.linspace(1, 0, steps + 1)[:steps]
|
| 27 |
+
return list(shift * s / (1 + (shift - 1) * s)) + [0.0]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class WorldGenerator:
|
| 31 |
+
"""Lazy holder for the base model. 5.7 GB on disk, ~2.6 GB resident in bf16."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, weights, cfg, device="cuda"):
|
| 34 |
+
self.weights = Path(weights)
|
| 35 |
+
self.cfg = cfg
|
| 36 |
+
self.device = device
|
| 37 |
+
self._model = None
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def loaded(self):
|
| 41 |
+
return self._model is not None
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def available(self):
|
| 45 |
+
return self.weights.exists()
|
| 46 |
+
|
| 47 |
+
def load(self):
|
| 48 |
+
if self._model is not None:
|
| 49 |
+
return
|
| 50 |
+
if not self.available:
|
| 51 |
+
raise FileNotFoundError(
|
| 52 |
+
f"{self.weights} not found — world generation needs the Wan2.1 base "
|
| 53 |
+
"transformer. Re-run setup.sh without SKIP_BASE=1.")
|
| 54 |
+
from safetensors.torch import load_file
|
| 55 |
+
from wan.modules.model import WanModel
|
| 56 |
+
|
| 57 |
+
c = self.cfg
|
| 58 |
+
m = WanModel(dim=c.dim, ffn_dim=c.ffn_dim, freq_dim=c.freq_dim,
|
| 59 |
+
num_heads=c.num_heads, num_layers=c.num_layers,
|
| 60 |
+
window_size=c.window_size, qk_norm=True,
|
| 61 |
+
cross_attn_norm=True, eps=1e-6)
|
| 62 |
+
m.load_state_dict(load_file(str(self.weights)), strict=True)
|
| 63 |
+
self._model = m.to(self.device, c.param_dtype).eval().requires_grad_(False)
|
| 64 |
+
|
| 65 |
+
def unload(self):
|
| 66 |
+
self._model = None
|
| 67 |
+
torch.cuda.empty_cache()
|
| 68 |
+
|
| 69 |
+
@torch.no_grad()
|
| 70 |
+
def generate(self, pos, neg, size=(640, 368), steps=30, guide=5.0, shift=5.0,
|
| 71 |
+
seed=0, progress=None):
|
| 72 |
+
"""pos/neg: [1, 512, 4096] umt5 embeddings -> latents [16, 21, h, w].
|
| 73 |
+
|
| 74 |
+
`progress(i, steps)` fires after every step so the UI can show a real
|
| 75 |
+
determinate bar rather than a spinner for a minute.
|
| 76 |
+
"""
|
| 77 |
+
self.load()
|
| 78 |
+
m = self._model
|
| 79 |
+
dtype = self.cfg.param_dtype
|
| 80 |
+
h, w = size[1] // 8, size[0] // 8
|
| 81 |
+
seq_len = LATENT_FRAMES * (h // 2) * (w // 2)
|
| 82 |
+
|
| 83 |
+
t0 = time.time()
|
| 84 |
+
g = torch.Generator(device=self.device).manual_seed(int(seed))
|
| 85 |
+
x = torch.randn((16, LATENT_FRAMES, h, w), generator=g,
|
| 86 |
+
device=self.device, dtype=torch.float32)
|
| 87 |
+
ctx_p = [pos.to(self.device, dtype).squeeze(0)]
|
| 88 |
+
ctx_n = [neg.to(self.device, dtype).squeeze(0)]
|
| 89 |
+
|
| 90 |
+
sig = _sampling_sigmas(steps, shift)
|
| 91 |
+
for i in range(steps):
|
| 92 |
+
t = torch.full((1,), sig[i] * 1000.0, device=self.device)
|
| 93 |
+
with torch.amp.autocast("cuda", dtype=dtype):
|
| 94 |
+
vc = m([x.to(dtype)], t=t, context=ctx_p, seq_len=seq_len)[0].float()
|
| 95 |
+
vu = m([x.to(dtype)], t=t, context=ctx_n, seq_len=seq_len)[0].float()
|
| 96 |
+
# classifier free guidance: the student never needs this, the base does
|
| 97 |
+
x = x + (sig[i + 1] - sig[i]) * (vu + guide * (vc - vu))
|
| 98 |
+
if progress:
|
| 99 |
+
progress(i + 1, steps)
|
| 100 |
+
|
| 101 |
+
return x, time.time() - t0
|
wanstreamer/stream.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Few-step block-causal streaming: the deployment loop, and the loop the
|
| 2 |
+
self-forcing trainer rolls out through.
|
| 3 |
+
|
| 4 |
+
One class serves both, because that identity *is* the method. Self-forcing only
|
| 5 |
+
means anything if the context the student is trained on is the context it will
|
| 6 |
+
actually receive -- its own previous output, encoded into the same K/V cache, at
|
| 7 |
+
the same noise levels, with the same number of steps.
|
| 8 |
+
|
| 9 |
+
Per streaming unit (a block of `block_frames` latent frames):
|
| 10 |
+
|
| 11 |
+
z = noise
|
| 12 |
+
for t, t_next in schedule: # e.g. 1.0 -> .75 -> .5 -> .25 -> 0
|
| 13 |
+
x0 = z - t * G(z, t | clean K/V of world + committed events)
|
| 14 |
+
z = (1 - t_next) * x0 + t_next * eps (fresh eps; last step
|
| 15 |
+
leaves x0 as-is)
|
| 16 |
+
G(x0, t=0) -> write this block's clean K/V, commit
|
| 17 |
+
|
| 18 |
+
The re-run at t=0 is what keeps the cache clean: every committed key/value in
|
| 19 |
+
the prefix was produced from a *clean* latent, which is the condition Stage 1
|
| 20 |
+
trained under. It costs one extra forward per unit and is worth it.
|
| 21 |
+
|
| 22 |
+
The world's K/V is pinned; eviction under a bounded window drops the oldest
|
| 23 |
+
events only, so memory and per-unit cost are flat in stream length while scene
|
| 24 |
+
and subject identity persist (v0.3's "world + event stream").
|
| 25 |
+
"""
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
|
| 30 |
+
from . import blockcausal as bc
|
| 31 |
+
from .core import make_rope_table, make_cache, latent_geometry
|
| 32 |
+
from .graphrunner import GraphedForward, SteadyStateGraphs
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def few_step_schedule(num_steps, t_max=1.0, shift=1.0):
|
| 36 |
+
"""Flow fractions, highest first.
|
| 37 |
+
|
| 38 |
+
shift == 1 gives the uniform spacing DMD-distilled few-step video models use
|
| 39 |
+
(CausVid / Self-Forcing: 1.0, .75, .5, .25). shift > 1 reproduces Wan's own
|
| 40 |
+
inference schedule, which is the right one for evaluating a many-step,
|
| 41 |
+
not-yet-distilled model.
|
| 42 |
+
"""
|
| 43 |
+
ts = [t_max * (num_steps - i) / num_steps for i in range(num_steps)]
|
| 44 |
+
if shift == 1.0:
|
| 45 |
+
return ts
|
| 46 |
+
return [shift * t / (1 + (shift - 1) * t) for t in ts]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class FewStepStreamer:
|
| 50 |
+
def __init__(self, model, width=640, height=368, max_frames=256,
|
| 51 |
+
device='cuda', dtype=torch.bfloat16, window_frames=None,
|
| 52 |
+
time_scale=1000.0, cache_frames=None, block_frames=3,
|
| 53 |
+
num_steps=4, rope=None, shift=1.0, sampler='renoise',
|
| 54 |
+
cuda_graphs=False, noisy_context=False):
|
| 55 |
+
self.model = model
|
| 56 |
+
self.device = device
|
| 57 |
+
self.dtype = dtype
|
| 58 |
+
self.time_scale = time_scale
|
| 59 |
+
self.block_frames = block_frames
|
| 60 |
+
self.schedule = few_step_schedule(num_steps, shift=shift)
|
| 61 |
+
self.sampler = sampler
|
| 62 |
+
self.h_lat, self.w_lat, self.hp, self.wp = latent_geometry(width, height)
|
| 63 |
+
self.S = self.hp * self.wp
|
| 64 |
+
self.max_frames = max_frames
|
| 65 |
+
self.window_frames = window_frames
|
| 66 |
+
self.rope = rope if rope is not None else make_rope_table(
|
| 67 |
+
model, self.hp, self.wp, max_frames, device)
|
| 68 |
+
self.cache = make_cache(model, self.S, cache_frames or max_frames,
|
| 69 |
+
device, dtype)
|
| 70 |
+
self.kv = bc.BufferKV(self.cache)
|
| 71 |
+
self.n_world = 0
|
| 72 |
+
self.n_frames = 0
|
| 73 |
+
# Training-only: pushes the event window this far from the pinned world
|
| 74 |
+
# in RoPE index space, simulating a long stream. At deployment the gap
|
| 75 |
+
# arises naturally from evicted events, so this stays 0.
|
| 76 |
+
self.rope_gap = 0
|
| 77 |
+
# per-channel moment matching towards the world (0 = off); see _renorm
|
| 78 |
+
self.latent_norm = 0.0
|
| 79 |
+
self.ref_stats = None
|
| 80 |
+
self.graphs = SteadyStateGraphs() if cuda_graphs else None
|
| 81 |
+
# "Noisy context": commit the K/V the LAST denoising step already wrote
|
| 82 |
+
# instead of re-running the finished block at t=0. That drops the cost
|
| 83 |
+
# of a unit from N+1 forwards to N -- a third of the latency at N=2,
|
| 84 |
+
# which is the single largest inference-cost lever left in this loop.
|
| 85 |
+
# It is NOT a free inference-time swap: every committed key then comes
|
| 86 |
+
# from a latent at the schedule's lowest noise level rather than a clean
|
| 87 |
+
# one, and Stage 1 and DMD both trained against a clean prefix. Turning
|
| 88 |
+
# it on without retraining under the same convention changes the
|
| 89 |
+
# conditioning the student was distilled for. Exposed here so the
|
| 90 |
+
# latency claim can be measured before that training is paid for.
|
| 91 |
+
self.noisy_context = noisy_context
|
| 92 |
+
|
| 93 |
+
# ------------------------------------------------------------------ context
|
| 94 |
+
def set_text(self, ctx_emb, ctx_lens=None):
|
| 95 |
+
self.ctx = ctx_emb
|
| 96 |
+
# Resolve "is a cross-attention mask needed?" ONCE, here, and store None
|
| 97 |
+
# if it is not. Deciding it inside the forward costs a `.item()` on a
|
| 98 |
+
# device tensor -- a host sync, which is illegal during CUDA graph
|
| 99 |
+
# capture. Contexts are padded to the full text length (upstream's own
|
| 100 |
+
# WanModel.forward passes context_lens=None for exactly this reason), so
|
| 101 |
+
# None is not an approximation, it is the same computation.
|
| 102 |
+
full = ctx_emb.shape[1]
|
| 103 |
+
if ctx_lens is not None and int(torch.as_tensor(ctx_lens).min()) < full:
|
| 104 |
+
self.ctx_lens = ctx_lens
|
| 105 |
+
else:
|
| 106 |
+
self.ctx_lens = None
|
| 107 |
+
|
| 108 |
+
def _eager(self, z, tbl, e, e0):
|
| 109 |
+
return bc.block_forward(self.model, z, 0.0, 0, self.rope, self.ctx,
|
| 110 |
+
self.ctx_lens, kv=self.kv, dtype=self.dtype,
|
| 111 |
+
time_scale=self.time_scale, tbl=tbl, emb=(e, e0))
|
| 112 |
+
|
| 113 |
+
def _fwd(self, z, t, t_start):
|
| 114 |
+
tbl = self.rope.span(t_start, z.shape[2])
|
| 115 |
+
e, e0 = bc.time_embed(self.model, t, self.device, self.time_scale)
|
| 116 |
+
if self.graphs is not None and not torch.is_grad_enabled():
|
| 117 |
+
# Key on the cache length: that is the only thing that changes the
|
| 118 |
+
# shapes, and a bounded window makes it constant once saturated.
|
| 119 |
+
g = self.graphs.get(
|
| 120 |
+
(self.cache.length, z.shape[2]),
|
| 121 |
+
lambda: GraphedForward(self._eager, tuple(z.shape),
|
| 122 |
+
tuple(tbl.shape), tuple(e.shape),
|
| 123 |
+
tuple(e0.shape), self.device))
|
| 124 |
+
if g is not None:
|
| 125 |
+
return g(z, tbl, e, e0)
|
| 126 |
+
return self._eager(z, tbl, e, e0)
|
| 127 |
+
|
| 128 |
+
def _commit(self, n):
|
| 129 |
+
self.cache.commit(n * self.S)
|
| 130 |
+
self.n_frames += n
|
| 131 |
+
if self.window_frames is not None:
|
| 132 |
+
self.trim_to_window(self.window_frames)
|
| 133 |
+
|
| 134 |
+
def trim_to_window(self, window_frames):
|
| 135 |
+
"""Evict the oldest events so only `window_frames` of them remain.
|
| 136 |
+
|
| 137 |
+
Deployment calls this from `_commit` after every unit, which is what
|
| 138 |
+
makes per-unit cost flat in stream length. The self-forcing trainer
|
| 139 |
+
cannot: it records denoising steps during a rollout and redoes them
|
| 140 |
+
under gradient afterwards against `buffer[:prefix]`, and an eviction in
|
| 141 |
+
between silently changes what those tokens are (see
|
| 142 |
+
StreamingKVCache.evictions). So the trainer leaves `window_frames` None,
|
| 143 |
+
rolls one iteration's blocks with the buffer growing monotonically, and
|
| 144 |
+
calls this itself at the iteration boundary -- the one point where no
|
| 145 |
+
recorded step is still owed its prefix.
|
| 146 |
+
|
| 147 |
+
The world stays pinned either way; only events are dropped.
|
| 148 |
+
"""
|
| 149 |
+
protect = self.n_world * self.S
|
| 150 |
+
budget = (self.n_world + window_frames) * self.S
|
| 151 |
+
excess = self.cache.num_tokens - budget
|
| 152 |
+
if excess > 0:
|
| 153 |
+
self.cache.evict_front(excess, protect=protect)
|
| 154 |
+
|
| 155 |
+
# -------------------------------------------------------------------- world
|
| 156 |
+
@torch.no_grad()
|
| 157 |
+
def set_world(self, world_latents):
|
| 158 |
+
"""Prime the cache from clean world latents, attended bidirectionally."""
|
| 159 |
+
self.cache.reset()
|
| 160 |
+
self.n_frames = 0
|
| 161 |
+
z = world_latents.to(self.device).unsqueeze(0) \
|
| 162 |
+
if world_latents.dim() == 4 else world_latents.to(self.device)
|
| 163 |
+
F = z.shape[2]
|
| 164 |
+
self._fwd(z, 0.0, 0)
|
| 165 |
+
self._commit(F)
|
| 166 |
+
self.n_world = F
|
| 167 |
+
# Per-channel moments of the world: the in-distribution reference for
|
| 168 |
+
# `latent_norm`. Computed over (F, H, W) per channel.
|
| 169 |
+
w = z[0].float()
|
| 170 |
+
self.ref_stats = (w.mean(dim=(1, 2, 3), keepdim=True).unsqueeze(0),
|
| 171 |
+
w.std(dim=(1, 2, 3), keepdim=True).unsqueeze(0))
|
| 172 |
+
return F
|
| 173 |
+
|
| 174 |
+
def _renorm(self, z, strength):
|
| 175 |
+
"""Pull a finished block's PER-CHANNEL moments back towards the world's.
|
| 176 |
+
|
| 177 |
+
Long autoregressive rollouts drift, and here the drift is measurably a
|
| 178 |
+
per-channel one: the decoded stream develops colour casts and saturated
|
| 179 |
+
blotches long before it loses structure. A distilled student inherits
|
| 180 |
+
this from the CFG-guided real score it was matched to -- CFG moves mass
|
| 181 |
+
towards higher-contrast, more saturated samples, and in a feedback loop
|
| 182 |
+
(output becomes context becomes output) that bias compounds.
|
| 183 |
+
|
| 184 |
+
Matching the first two moments per channel against the world is the
|
| 185 |
+
cheapest correction that targets exactly that, and it is applied BEFORE
|
| 186 |
+
the block's K/V is recomputed and committed, so the cached history stays
|
| 187 |
+
in distribution and the correction cannot itself accumulate. It cannot
|
| 188 |
+
fix a wrong *direction*, only a wrong per-channel scale and offset.
|
| 189 |
+
"""
|
| 190 |
+
if strength <= 0 or getattr(self, 'ref_stats', None) is None:
|
| 191 |
+
return z
|
| 192 |
+
mu, sd = self.ref_stats
|
| 193 |
+
zm = z.mean(dim=(2, 3, 4), keepdim=True)
|
| 194 |
+
zs = z.std(dim=(2, 3, 4), keepdim=True)
|
| 195 |
+
z_n = (z - zm) / (zs + 1e-5) * sd.to(z.dtype) + mu.to(z.dtype)
|
| 196 |
+
return (1.0 - strength) * z + strength * z_n
|
| 197 |
+
|
| 198 |
+
# ------------------------------------------------------------------- events
|
| 199 |
+
def generate_block(self, generator=None, record=None, n_frames=None):
|
| 200 |
+
"""Emit one streaming unit. Returns clean latents [1, C, b, H, W]."""
|
| 201 |
+
b = n_frames or self.block_frames
|
| 202 |
+
t0 = bc.event_rope_index(self.n_frames, self.n_world, self.rope_gap)
|
| 203 |
+
z = torch.randn(1, 16, b, self.h_lat, self.w_lat, device=self.device,
|
| 204 |
+
dtype=torch.float32, generator=generator)
|
| 205 |
+
sched = self.schedule
|
| 206 |
+
for i, t in enumerate(sched):
|
| 207 |
+
if record is not None:
|
| 208 |
+
record.append({'block_start': t0, 'step': i, 't': t,
|
| 209 |
+
'z': z.detach()})
|
| 210 |
+
v = self._fwd(z, t, t0).float()
|
| 211 |
+
x0 = z - t * v
|
| 212 |
+
tn = sched[i + 1] if i + 1 < len(sched) else 0.0
|
| 213 |
+
if tn == 0.0:
|
| 214 |
+
z = x0
|
| 215 |
+
elif self.sampler == 'renoise':
|
| 216 |
+
# DMD-distilled few-step sampler: re-noise the x0 prediction to
|
| 217 |
+
# the next level with FRESH noise. This is the sampler the
|
| 218 |
+
# student is distilled under, so it is also the one it must be
|
| 219 |
+
# rolled out with.
|
| 220 |
+
eps = torch.randn(z.shape, device=z.device, dtype=z.dtype,
|
| 221 |
+
generator=generator)
|
| 222 |
+
z = (1 - tn) * x0 + tn * eps
|
| 223 |
+
else:
|
| 224 |
+
z = z + (tn - t) * v # deterministic Euler, for the
|
| 225 |
+
# undistilled many-step model
|
| 226 |
+
z = self._renorm(z, self.latent_norm)
|
| 227 |
+
if not self.noisy_context:
|
| 228 |
+
self._fwd(z, 0.0, t0) # clean K/V for the committed block
|
| 229 |
+
# else: the scratch region still holds the K/V written by the final
|
| 230 |
+
# denoising forward, so committing promotes that instead and the extra
|
| 231 |
+
# pass is skipped entirely. (`_renorm` is then not reflected in the
|
| 232 |
+
# cache, which is another reason the two options are not interchangeable
|
| 233 |
+
# without retraining.)
|
| 234 |
+
self._commit(b)
|
| 235 |
+
return z
|
| 236 |
+
|
| 237 |
+
@torch.no_grad()
|
| 238 |
+
def stream(self, num_blocks, seed=0, log_every=0, n_frames=None):
|
| 239 |
+
g = torch.Generator(device=self.device).manual_seed(seed)
|
| 240 |
+
lats, times = [], []
|
| 241 |
+
for u in range(num_blocks):
|
| 242 |
+
torch.cuda.synchronize()
|
| 243 |
+
t0 = time.perf_counter()
|
| 244 |
+
z = self.generate_block(generator=g, n_frames=n_frames)
|
| 245 |
+
torch.cuda.synchronize()
|
| 246 |
+
dt = time.perf_counter() - t0
|
| 247 |
+
lats.append(z[0])
|
| 248 |
+
times.append(dt)
|
| 249 |
+
if log_every and (u + 1) % log_every == 0:
|
| 250 |
+
b = n_frames or self.block_frames
|
| 251 |
+
print(f' unit {u+1}/{num_blocks}: {dt*1000:7.1f} ms '
|
| 252 |
+
f'({dt/b*1000:6.1f} ms/latent frame, '
|
| 253 |
+
f'cache {self.cache.num_tokens} tok)', flush=True)
|
| 254 |
+
return torch.cat(lats, dim=1), times
|
| 255 |
+
|
| 256 |
+
# --------------------------------------------------- self-forcing rollout
|
| 257 |
+
@torch.no_grad()
|
| 258 |
+
def rollout_record(self, num_blocks, generator=None):
|
| 259 |
+
"""Roll out `num_blocks` units and keep what the DMD step needs.
|
| 260 |
+
|
| 261 |
+
Returns (x0_blocks, records). `records[i]` holds, for every denoising
|
| 262 |
+
step of block i, the exact input latent and timestep, plus the cache
|
| 263 |
+
length at that point -- enough to recompute any single step under
|
| 264 |
+
gradient without re-running the rollout.
|
| 265 |
+
"""
|
| 266 |
+
out, recs = [], []
|
| 267 |
+
for _ in range(num_blocks):
|
| 268 |
+
rec = []
|
| 269 |
+
prefix = self.cache.num_tokens
|
| 270 |
+
ev = self.cache.evictions
|
| 271 |
+
z = self.generate_block(generator=generator, record=rec)
|
| 272 |
+
for r in rec:
|
| 273 |
+
r['prefix'] = prefix
|
| 274 |
+
r['evictions'] = ev
|
| 275 |
+
out.append(z)
|
| 276 |
+
recs.append(rec)
|
| 277 |
+
return out, recs
|
| 278 |
+
|
| 279 |
+
def prefix_intact(self, rec_step):
|
| 280 |
+
"""Is this recorded step's prefix still literally `buffer[:prefix]`?
|
| 281 |
+
|
| 282 |
+
False once anything has been evicted since it was recorded -- the tokens
|
| 283 |
+
at those indices are now different tokens. The trainer must not take a
|
| 284 |
+
gradient through a step for which this is False; the forward would
|
| 285 |
+
succeed and quietly train against the wrong context.
|
| 286 |
+
"""
|
| 287 |
+
return rec_step.get('evictions', 0) == self.cache.evictions
|
| 288 |
+
|
| 289 |
+
def recompute_step(self, rec_step, prefix_kv):
|
| 290 |
+
"""Differentiably redo one recorded denoising step against a detached
|
| 291 |
+
prefix. Returns that step's x0 prediction."""
|
| 292 |
+
z, t = rec_step['z'], rec_step['t']
|
| 293 |
+
v = bc.block_forward(self.model, z, t, rec_step['block_start'],
|
| 294 |
+
self.rope, self.ctx, self.ctx_lens, kv=prefix_kv,
|
| 295 |
+
dtype=self.dtype, time_scale=self.time_scale,
|
| 296 |
+
prefix_upto=rec_step['prefix'],
|
| 297 |
+
grad_checkpoint=True)
|
| 298 |
+
return z - t * v.float()
|
wanstreamer/text.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt encoding with a content-addressed cache.
|
| 2 |
+
|
| 3 |
+
The T5 encoder (umt5-xxl) runs on CPU here to keep the GPU free, which costs
|
| 4 |
+
~40 s per prompt -- worth caching. The original cache key in run_streaming.py was
|
| 5 |
+
`abs(hash(prompt)) % 10**8`; Python salts `hash()` for str per process
|
| 6 |
+
(PYTHONHASHSEED), so the key changed on every invocation and the cache never hit.
|
| 7 |
+
That is why diag/ accumulated 19 ctx_*.pt files for ~3 distinct prompts. sha1 of
|
| 8 |
+
the text is stable across processes.
|
| 9 |
+
"""
|
| 10 |
+
import gc
|
| 11 |
+
import hashlib
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def prompt_key(prompt):
|
| 18 |
+
return hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:16]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def encode_prompt(prompt, neg_prompt, ckpt_dir, cfg, cache_dir, text_len=512):
|
| 22 |
+
"""Return (pos, neg) embeddings [1, text_len, dim], cached on disk."""
|
| 23 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 24 |
+
path = os.path.join(cache_dir, f'ctx_{prompt_key(prompt)}.pt')
|
| 25 |
+
if os.path.exists(path):
|
| 26 |
+
return torch.load(path, map_location='cpu')
|
| 27 |
+
|
| 28 |
+
from wan.modules.t5 import T5EncoderModel
|
| 29 |
+
print(f'Loading T5 (CPU) to encode prompt -> {os.path.basename(path)} ...')
|
| 30 |
+
t5 = T5EncoderModel(text_len=text_len, dtype=cfg.t5_dtype,
|
| 31 |
+
device=torch.device('cpu'),
|
| 32 |
+
checkpoint_path=f'{ckpt_dir}/{cfg.t5_checkpoint}',
|
| 33 |
+
tokenizer_path=f'{ckpt_dir}/google/umt5-xxl')
|
| 34 |
+
|
| 35 |
+
def enc(p):
|
| 36 |
+
x = t5([p], torch.device('cpu'))[0].float()
|
| 37 |
+
if x.shape[0] < text_len:
|
| 38 |
+
x = torch.cat([x, torch.zeros(text_len - x.shape[0], x.shape[1])], 0)
|
| 39 |
+
return x[:text_len].unsqueeze(0)
|
| 40 |
+
|
| 41 |
+
blob = {'pos': enc(prompt), 'neg': enc(neg_prompt), 'prompt': prompt}
|
| 42 |
+
del t5
|
| 43 |
+
gc.collect()
|
| 44 |
+
torch.save(blob, path)
|
| 45 |
+
return blob
|