JonathanColetti commited on
Commit
36cdb93
·
verified ·
1 Parent(s): 28dd559

LiveWan streaming demo on ZeroGPU

Browse files
.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: green
5
- colorTo: red
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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