RockTalk commited on
Commit
8c5231a
·
verified ·
1 Parent(s): a7691b5

Add self-contained inference.py + bundled lance_mlx package

Browse files
Files changed (1) hide show
  1. inference.py +255 -0
inference.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """End-to-end Text-to-Video (and Text-to-Image) inference for Lance-3B-Video-MLX.
3
+
4
+ Self-contained: works from this repo directory after `huggingface-cli download`.
5
+ Auto-fetches the Wan 2.2 VAE companion repo (`RockTalk/Wan2.2-VAE-MLX`) on first
6
+ run if its weights aren't already present alongside this script.
7
+
8
+ Usage:
9
+ # T2V (default — 9-frame video at 256x256, ~22 s on M4 Studio)
10
+ python inference.py --prompt "a calm ocean wave rolling onto a sandy beach"
11
+
12
+ # Tune frame count: T = (T_lat - 1) * 4 + 1
13
+ # T_lat=1 -> 1 frame (image), T_lat=3 -> 9, T_lat=8 -> 29, T_lat=31 -> 121
14
+ python inference.py --prompt "..." --t-lat 8
15
+
16
+ # T2I fast path
17
+ python inference.py --prompt "..." --t-lat 1 --size 512 --steps 30
18
+
19
+ Outputs:
20
+ - <out>.png — horizontal strip of all frames
21
+ - <out>_frame*.png — each frame as a separate file
22
+ - <out>.mp4 — MP4 (if --mp4 and `imageio[ffmpeg]` is installed)
23
+
24
+ Verified on M4 Studio (128 GB). Requires Apple Silicon with MLX >= 0.29.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import sys
31
+ import time
32
+ from pathlib import Path
33
+
34
+ import mlx.core as mx
35
+ import numpy as np
36
+ from PIL import Image
37
+
38
+ _materialize = getattr(mx, "eval")
39
+
40
+ REPO_DIR = Path(__file__).resolve().parent
41
+ sys.path.insert(0, str(REPO_DIR))
42
+
43
+ from lance_mlx.lance import Lance, LanceConfig # noqa: E402
44
+ from lance_mlx.vae_wan22 import Wan2_2_VAE # noqa: E402
45
+
46
+ try:
47
+ from mlx_vlm.models.qwen2_5_vl.config import (
48
+ ModelConfig, TextConfig, VisionConfig,
49
+ )
50
+ except ImportError as e:
51
+ raise SystemExit("mlx-vlm is required. Install with: pip install 'mlx-vlm>=0.3'") from e
52
+
53
+ try:
54
+ from transformers import AutoTokenizer
55
+ except ImportError as e:
56
+ raise SystemExit("transformers is required. Install with: pip install transformers") from e
57
+
58
+
59
+ def build_lance_config(cfg_json: dict) -> LanceConfig:
60
+ qwen = cfg_json["qwen2_5_vl_config"]
61
+ vc = qwen["vision_config"]
62
+ text_cfg = TextConfig(
63
+ model_type="qwen2_5_vl",
64
+ hidden_size=qwen["hidden_size"],
65
+ intermediate_size=qwen["intermediate_size"],
66
+ num_hidden_layers=qwen["num_hidden_layers"],
67
+ num_attention_heads=qwen["num_attention_heads"],
68
+ num_key_value_heads=qwen["num_key_value_heads"],
69
+ vocab_size=qwen["vocab_size"],
70
+ rms_norm_eps=qwen["rms_norm_eps"],
71
+ rope_theta=qwen["rope_theta"],
72
+ rope_scaling=qwen["rope_scaling"],
73
+ tie_word_embeddings=qwen.get("tie_word_embeddings", True),
74
+ )
75
+ vision_cfg = VisionConfig(
76
+ model_type="qwen2_5_vl",
77
+ hidden_size=vc["hidden_size"], out_hidden_size=vc["out_hidden_size"],
78
+ intermediate_size=vc["intermediate_size"], depth=vc["depth"],
79
+ num_heads=vc["num_heads"], patch_size=vc["patch_size"],
80
+ spatial_merge_size=vc["spatial_merge_size"], in_channels=vc["in_chans"],
81
+ spatial_patch_size=vc["spatial_patch_size"],
82
+ temporal_patch_size=vc["temporal_patch_size"],
83
+ window_size=vc["window_size"],
84
+ fullatt_block_indexes=vc["fullatt_block_indexes"],
85
+ tokens_per_second=vc["tokens_per_second"],
86
+ )
87
+ mc = ModelConfig(
88
+ text_config=text_cfg, vision_config=vision_cfg, model_type="qwen2_5_vl",
89
+ image_token_id=qwen["image_token_id"],
90
+ video_token_id=qwen["video_token_id"],
91
+ vision_start_token_id=qwen["vision_start_token_id"],
92
+ vision_end_token_id=qwen["vision_end_token_id"],
93
+ vision_token_id=qwen["vision_token_id"],
94
+ )
95
+ return LanceConfig(
96
+ qwen_config=mc,
97
+ latent_patch_size=tuple(cfg_json["latent_patch_size"]),
98
+ max_latent_size=cfg_json["max_latent_size"],
99
+ max_num_frames=cfg_json["max_num_frames"],
100
+ max_num_latent_frames_override=cfg_json.get("max_num_latent_frames"),
101
+ latent_channel=cfg_json["latent_channel"],
102
+ vae_downsample_spatial=cfg_json["vae_downsample_spatial"],
103
+ vae_downsample_temporal=cfg_json["vae_downsample_temporal"],
104
+ timestep_shift=cfg_json["timestep_shift"],
105
+ )
106
+
107
+
108
+ def ensure_vae_weights(repo_dir: Path) -> Path:
109
+ candidate = repo_dir / "wan22_vae.safetensors"
110
+ if candidate.exists():
111
+ return candidate
112
+ try:
113
+ from huggingface_hub import hf_hub_download
114
+ except ImportError as e:
115
+ raise SystemExit(
116
+ "huggingface_hub is required to fetch the Wan VAE. "
117
+ "Install with: pip install huggingface_hub"
118
+ ) from e
119
+ print("[setup] Fetching Wan 2.2 VAE from RockTalk/Wan2.2-VAE-MLX ...")
120
+ downloaded = Path(hf_hub_download(
121
+ repo_id="RockTalk/Wan2.2-VAE-MLX",
122
+ filename="model.safetensors",
123
+ ))
124
+ target = repo_dir / "wan22_vae.safetensors"
125
+ try:
126
+ target.symlink_to(downloaded)
127
+ except OSError:
128
+ import shutil
129
+ shutil.copy(downloaded, target)
130
+ return target
131
+
132
+
133
+ def save_outputs(video_np: np.ndarray, out_path: Path, want_mp4: bool, fps: int) -> None:
134
+ """video_np: (T, H, W, 3) in [-1, 1]. Saves strip + per-frame PNGs + optional MP4."""
135
+ video_u8 = np.clip((video_np + 1.0) * 127.5, 0, 255).astype(np.uint8)
136
+ out_path.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ strip = np.concatenate(list(video_u8), axis=1) # (H, T*W, 3)
139
+ Image.fromarray(strip).save(out_path)
140
+ print(f"[ok] frame strip -> {out_path}")
141
+
142
+ base = out_path.with_suffix("")
143
+ for i, frame in enumerate(video_u8):
144
+ Image.fromarray(frame).save(f"{base}_frame{i:02d}.png")
145
+ print(f"[ok] per-frame PNGs -> {base}_frame*.png")
146
+
147
+ if want_mp4:
148
+ try:
149
+ import imageio.v3 as iio
150
+ mp4_path = out_path.with_suffix(".mp4")
151
+ iio.imwrite(mp4_path, video_u8, fps=fps, codec="libx264")
152
+ print(f"[ok] mp4 -> {mp4_path}")
153
+ except Exception as exc:
154
+ print(f"[warn] MP4 export failed: {exc}")
155
+ print(" Install with: pip install 'imageio[ffmpeg]'")
156
+
157
+
158
+ def main(args: argparse.Namespace) -> None:
159
+ repo = REPO_DIR
160
+ n_frames = (args.t_lat - 1) * 4 + 1
161
+ print("=== Lance-3B-Video-MLX ===")
162
+ print(f"prompt: {args.prompt!r}")
163
+ print(f"out: {args.out}")
164
+ print(f"size: {args.size}x{args.size} steps: {args.steps} "
165
+ f"T_lat={args.t_lat} → {n_frames} frames cfg: {args.cfg}\n")
166
+
167
+ t0 = time.time()
168
+ cfg_json = json.loads((repo / "config.json").read_text())
169
+ lance_cfg = build_lance_config(cfg_json)
170
+ model = Lance(lance_cfg)
171
+ print(f"[ok] Lance built ({time.time()-t0:.1f}s)")
172
+
173
+ t0 = time.time()
174
+ weights = mx.load(str(repo / "model.safetensors"))
175
+ non_vit = {k: v for k, v in weights.items() if not k.startswith("vit_model.")}
176
+ n_vit = len(weights) - len(non_vit)
177
+ model.load_weights(list(non_vit.items()), strict=True)
178
+ _materialize(model.parameters())
179
+ print(f"[ok] strict load — {len(non_vit)} tensors ({time.time()-t0:.1f}s, "
180
+ f"dropped {n_vit} ViT tensors not needed for generation)")
181
+
182
+ vae_path = ensure_vae_weights(repo)
183
+ t0 = time.time()
184
+ vae = Wan2_2_VAE(
185
+ z_dim=48, c_dim=160, dim_mult=(1, 2, 4, 4),
186
+ temperal_downsample=(False, True, True),
187
+ )
188
+ vae.model.load_weights(list(mx.load(str(vae_path)).items()), strict=True)
189
+ _materialize(vae.model.parameters())
190
+ print(f"[ok] VAE strict load from {vae_path.name} ({time.time()-t0:.1f}s)")
191
+
192
+ tok = AutoTokenizer.from_pretrained(str(repo))
193
+ ids = tok(args.prompt, add_special_tokens=False, return_tensors="np").input_ids[0]
194
+ text_ids = mx.array(ids, dtype=mx.int32)
195
+
196
+ def tok_id(s: str) -> int:
197
+ out = tok.convert_tokens_to_ids(s)
198
+ if out is None or out == tok.unk_token_id:
199
+ raise RuntimeError(f"special token {s!r} not found in tokenizer")
200
+ return out
201
+
202
+ special_token_ids = {
203
+ "bos": tok_id("<|im_start|>"),
204
+ "eos": tok_id("<|im_end|>"),
205
+ "start_of_image": tok_id("<|vision_start|>"),
206
+ "end_of_image": tok_id("<|vision_end|>"),
207
+ "image_token_id": cfg_json["qwen2_5_vl_config"]["image_token_id"],
208
+ }
209
+ print(f"[ok] tokenized: {len(ids)} prompt tokens")
210
+
211
+ H_lat = args.size // lance_cfg.vae_downsample_spatial
212
+ W_lat = args.size // lance_cfg.vae_downsample_spatial
213
+ latent_shape = (args.t_lat, H_lat, W_lat)
214
+ print(f"\nRunning {args.steps}-step denoising loop ...")
215
+ t0 = time.time()
216
+ final_latent = model.sample_t2i(
217
+ prompt_token_ids=text_ids,
218
+ latent_shape=latent_shape,
219
+ special_token_ids=special_token_ids,
220
+ num_steps=args.steps,
221
+ timestep_shift=lance_cfg.timestep_shift,
222
+ seed=args.seed,
223
+ cfg_scale=args.cfg,
224
+ )
225
+ _materialize(final_latent)
226
+ sample_dt = time.time() - t0
227
+ print(f"[ok] sampled. latent {final_latent.shape} "
228
+ f"({sample_dt:.1f}s, {sample_dt/args.steps*1000:.0f} ms/step)")
229
+
230
+ print("Decoding through VAE (streaming for T>1) ...")
231
+ t0 = time.time()
232
+ video = vae.decode(final_latent)
233
+ _materialize(video)
234
+ print(f"[ok] VAE decode ({time.time()-t0:.1f}s) shape={video.shape}")
235
+
236
+ video_np = np.asarray(video).squeeze(0) # (T, H, W, 3)
237
+ save_outputs(video_np, Path(args.out), want_mp4=args.mp4, fps=args.fps)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ ap = argparse.ArgumentParser()
242
+ ap.add_argument("--prompt", default="a calm ocean wave rolling onto a sandy beach")
243
+ ap.add_argument("--out", default="output.png")
244
+ ap.add_argument("--steps", type=int, default=24)
245
+ ap.add_argument("--size", type=int, default=256,
246
+ help="square frame size (256 recommended for T2V)")
247
+ ap.add_argument("--t-lat", type=int, default=3,
248
+ help="latent frame count; output frames = (t_lat-1)*4 + 1")
249
+ ap.add_argument("--seed", type=int, default=0)
250
+ ap.add_argument("--cfg", type=float, default=4.0)
251
+ ap.add_argument("--mp4", action="store_true",
252
+ help="also export an MP4 (needs `pip install 'imageio[ffmpeg]'`)")
253
+ ap.add_argument("--fps", type=int, default=8,
254
+ help="MP4 frame rate")
255
+ main(ap.parse_args())