11# SPDX-License-Identifier: Apache-2.0
2- """Text-to-video generation for Wan2.1/FastMetal through the native MLX runtime.
3-
4- Scoped to the validated cookbook path only: text-to-video, DMD-distilled
5- denoising, a packed MLX DiT checkpoint, and TAEHV decode. Refine, fast-spatial,
6- RIFE fast mode, and prompt enrichment stay in the CLI script
7- (examples/inference/basic/mlx_wan_prompt_to_video.py) -- this module holds
8- only what a resident server needs to call repeatedly.
9-
10- Every step below reuses the same helpers the CLI script already runs (prompt
11- encoding, checkpoint loading, DMD scheduling, VAE decode) so this pipeline and
12- the script cannot silently drift into two different implementations of the
13- same math.
2+ """Text-to-video generation for Wan2.1 and Wan2.2-TI2V (FastMetal) through the
3+ native MLX runtime.
4+
5+ Scoped to each family's validated cookbook path only: text-to-video,
6+ DMD-distilled denoising, a packed MLX DiT checkpoint, and TAEHV decode.
7+ Refine, fast-spatial, RIFE fast mode, and prompt enrichment stay in the CLI
8+ scripts (examples/inference/basic/mlx_wan_prompt_to_video.py and
9+ mlx_wan22_generate.py) -- this module holds only what a resident server needs
10+ to call repeatedly.
11+
12+ Every step below reuses the same helpers the CLI scripts already run (prompt
13+ encoding, checkpoint loading, DMD scheduling, VAE decode) so these pipelines
14+ and the scripts cannot silently drift into different implementations of the
15+ same math. MLXWanPipeline (Wan2.1: 1.3B/14B) and MLXWan22Pipeline (Wan2.2:
16+ 5B) share the UMT5 prompt encoder and rotary-embedding builder below, since
17+ that piece is identical across both families; everything DiT/VAE-shaped is
18+ not, because the two are genuinely different architectures.
1419"""
1520from __future__ import annotations
1621
1722from dataclasses import dataclass , field
23+ import json
1824from pathlib import Path
1925import time
2026from typing import Any
3137
3238logger = init_logger (__name__ )
3339
34- # Wan2.1's VAE compresses 4x temporally and 8x spatially; this is fixed for
35- # every Wan2.1/FastMetal checkpoint this pipeline supports (see the guard in
36- # MLXWanPipeline.__init__ that rejects Wan2.2-TI2V's 48-channel checkpoints).
40+ # Wan2.1's VAE compresses 4x temporally and 8x spatially; Wan2.2-TI2V's
41+ # compresses 4x temporally and 16x spatially. The two families are not
42+ # interchangeable -- MLXWanPipeline/MLXWan22Pipeline each guard against being
43+ # pointed at the other's checkpoint (see _packed_dit_channels below).
3744_WAN21_TEMPORAL_COMPRESSION = 4
3845_WAN21_SPATIAL_COMPRESSION = 8
46+ _WAN21_CHANNELS = 16
47+ _WAN22_TEMPORAL_COMPRESSION = 4
48+ _WAN22_SPATIAL_COMPRESSION = 16
49+ _WAN22_CHANNELS = 48
3950_DEFAULT_DMD_STEPS = (1000 , 757 , 522 )
4051
4152
@@ -133,6 +144,24 @@ def _make_wan_rotary_embeddings(config: dict[str, Any], *, latent_frames: int, l
133144 return mx .array (freqs_cos .numpy ()).astype (mx .float32 ), mx .array (freqs_sin .numpy ()).astype (mx .float32 )
134145
135146
147+ def _packed_dit_channels (mlx_checkpoint : Path ) -> int | None :
148+ """Read in_channels from a packed mlx_dit.json, or None if unreadable.
149+
150+ A best-effort check: an unpacked/diffusers-style or missing checkpoint is
151+ left for generate() to fail on when it actually loads the weights.
152+ """
153+ manifest_path = mlx_checkpoint / "mlx_dit.json"
154+ if not manifest_path .is_file ():
155+ return None
156+ try :
157+ manifest = json .loads (manifest_path .read_text ())
158+ except (json .JSONDecodeError , OSError ):
159+ return None
160+ config = manifest .get ("config" , manifest )
161+ channels = config .get ("in_channels" )
162+ return int (channels ) if channels is not None else None
163+
164+
136165class MLXWanPipeline :
137166 """Text-to-video generation through the native MLX runtime (Wan2.1/FastMetal)."""
138167
@@ -145,6 +174,11 @@ def __init__(self, *, model_root: str | Path, mlx_checkpoint: str | Path) -> Non
145174 raise ValueError (str (error )) from error
146175 if not (self .model_root / "tokenizer" ).exists () or not (self .model_root / "text_encoder" ).exists ():
147176 raise FileNotFoundError (f"Missing tokenizer/ or text_encoder/ under { self .model_root } ." )
177+ channels = _packed_dit_channels (self .mlx_checkpoint )
178+ if channels == _WAN22_CHANNELS :
179+ raise ValueError (f"{ self .mlx_checkpoint } is a { channels } -channel Wan2.2-TI2V checkpoint "
180+ "(e.g. FastMetal-5B-QAD); MLXWanPipeline only supports Wan2.1's "
181+ f"{ _WAN21_CHANNELS } -channel checkpoints (1.3B/14B). Use MLXWan22Pipeline instead." )
148182
149183 def generate (
150184 self ,
@@ -263,3 +297,132 @@ def generate(
263297 k : v
264298 for k , v in timings .items () if k .endswith ("_gib" )
265299 })
300+
301+
302+ class MLXWan22Pipeline :
303+ """Text-to-video generation through the native MLX runtime (Wan2.2-TI2V/FastMetal-5B)."""
304+
305+ def __init__ (self , * , model_root : str | Path , mlx_checkpoint : str | Path ) -> None :
306+ self .model_root = Path (model_root )
307+ self .mlx_checkpoint = Path (mlx_checkpoint )
308+ try :
309+ raise_if_unsupported_mlx_checkpoint (self .mlx_checkpoint )
310+ except UnsupportedMLXCheckpointError as error :
311+ raise ValueError (str (error )) from error
312+ if not (self .model_root / "tokenizer" ).exists () or not (self .model_root / "text_encoder" ).exists ():
313+ raise FileNotFoundError (f"Missing tokenizer/ or text_encoder/ under { self .model_root } ." )
314+ channels = _packed_dit_channels (self .mlx_checkpoint )
315+ if channels is not None and channels != _WAN22_CHANNELS :
316+ raise ValueError (f"{ self .mlx_checkpoint } is a { channels } -channel checkpoint; MLXWan22Pipeline only "
317+ f"supports Wan2.2-TI2V's { _WAN22_CHANNELS } -channel checkpoints (FastMetal-5B-QAD). "
318+ "Use MLXWanPipeline for 1.3B/14B." )
319+
320+ def generate (
321+ self ,
322+ prompt : str ,
323+ * ,
324+ output_path : str | Path ,
325+ # Defaults match the validated FastMetal-5B-QAD cookbook recipe, not
326+ # mlx_wan22_generate.py's own argparse defaults (448x832x121), which
327+ # were never the evidence-backed shape for this checkpoint.
328+ height : int = 704 ,
329+ width : int = 1280 ,
330+ num_frames : int = 81 ,
331+ seed : int = 1234 ,
332+ dmd_denoising_steps : tuple [int , ...] = _DEFAULT_DMD_STEPS ,
333+ flow_shift : float = 5.0 ,
334+ fps : int = 24 ,
335+ max_sequence_length : int = 512 ,
336+ ) -> GenerationResult :
337+ import mlx .core as mx
338+ import torch
339+
340+ from fastvideo .mlx_runtime .wan22 import mlx_wan22_dit_from_mlx_checkpoint
341+ from fastvideo .mlx_runtime .wan22_sample import sample_wan22_dmd
342+ from fastvideo .mlx_runtime .wan_vae import decode_latents_to_video
343+
344+ timings : dict [str , float ] = {}
345+ mx .random .seed (seed )
346+
347+ started = time .perf_counter ()
348+ prompt_embeds = _encode_wan_prompt (model_root = self .model_root ,
349+ prompt = prompt ,
350+ max_sequence_length = max_sequence_length )
351+ timings ["encode_s" ] = time .perf_counter () - started
352+
353+ plan = plan_refine_resolutions (
354+ height = height ,
355+ width = width ,
356+ num_frames = num_frames ,
357+ vae_spatial_compression = _WAN22_SPATIAL_COMPRESSION ,
358+ vae_temporal_compression = _WAN22_TEMPORAL_COMPRESSION ,
359+ enabled = False ,
360+ )
361+
362+ started = time .perf_counter ()
363+ mx .clear_cache ()
364+ mx .reset_peak_memory ()
365+ dit = mlx_wan22_dit_from_mlx_checkpoint (self .mlx_checkpoint , compile = True )
366+ timings ["load_s" ] = time .perf_counter () - started
367+ timings ["load_peak_gib" ] = _peak_memory_gib ()
368+
369+ latents_seed = torch .Generator (device = "cpu" ).manual_seed (seed )
370+ latents_torch = torch .randn (
371+ (1 , int (
372+ dit .config ["in_channels" ]), plan .latent_frames , plan .stage1_latent_height , plan .stage1_latent_width ),
373+ generator = latents_seed ,
374+ dtype = torch .float32 ,
375+ )
376+ noise = mx .array (latents_torch .numpy ()).astype (mx .float16 )
377+ encoder_hidden_states = mx .array (prompt_embeds .numpy ()).astype (mx .float16 )
378+ freqs_cis = _make_wan_rotary_embeddings (
379+ dit .config ,
380+ latent_frames = plan .latent_frames ,
381+ latent_height = plan .stage1_latent_height ,
382+ latent_width = plan .stage1_latent_width ,
383+ )
384+
385+ started = time .perf_counter ()
386+ mx .reset_peak_memory ()
387+ # sample_wan22_dmd's own re-noise seed defaults to 0 in the CLI script
388+ # (--renoise-seed), independent of --seed; matched here rather than
389+ # exposed as a second knob nobody overrides in the validated recipe.
390+ latents = sample_wan22_dmd (
391+ dit ,
392+ encoder_hidden_states ,
393+ noise ,
394+ freqs_cis ,
395+ dmd_denoising_steps = list (dmd_denoising_steps ),
396+ flow_shift = flow_shift ,
397+ warp_denoising_step = True ,
398+ seed = 0 ,
399+ )
400+ timings ["denoise_s" ] = time .perf_counter () - started
401+ timings ["denoise_peak_gib" ] = _peak_memory_gib ()
402+
403+ latents_np = np .array (latents .astype (mx .float32 ))
404+ # Free the DiT before decode, matching the CLI script's phase-memory
405+ # policy -- the 5B DiT and the decoder are not held resident together.
406+ del dit , latents , encoder_hidden_states , freqs_cis , noise
407+ cleanup_mlx ()
408+
409+ started = time .perf_counter ()
410+ output_path = Path (output_path )
411+ decode_latents_to_video (
412+ latents_np ,
413+ output_path ,
414+ fps = fps ,
415+ backend = "taehv" ,
416+ z_dim = latents_np .shape [1 ],
417+ taehv_checkpoint = None ,
418+ torch_device = "auto" ,
419+ )
420+ timings ["decode_s" ] = time .perf_counter () - started
421+ cleanup_torch_mps ()
422+
423+ return GenerationResult (video_path = str (output_path ),
424+ timings = timings ,
425+ peak_memory_gib = {
426+ k : v
427+ for k , v in timings .items () if k .endswith ("_gib" )
428+ })
0 commit comments