Skip to content

wan_vae

Wan VAE decode helpers for Apple Silicon MLX inference.

Two decode backends:

  1. TAEHV (primary / fast) — Tiny AutoEncoder (madebyollin/taehv). Fully MLX-native Conv2d path. taew2_1.pth for Wan2.1 (z_dim=16), taew2_2.pth for Wan2.2 5B (z_dim=48, patch_size=2). Expected decode wall-clock ~seconds vs ~minutes for the full 3D VAE on MPS.

  2. Full AutoencoderKLWan (reference / quality) — denormalize with latents_mean / latents_std then torch decode (MPS preferred). Used for parity gates and when TAEHV is unavailable. A pure-MLX 3D-conv port of the residual Wan2.2 decoder is left as follow-up (causal feat-cache + residual up blocks are large); TAEHV covers the product latency path.

Diffusion latents from the DiT are not mean/std-normalized for TAEHV (matching taehv_decode.py); full VAE decode does denormalize first (matching mlx_wan_prompt_to_video.decode_latents_to_video).

Classes

fastvideo.mlx_runtime.wan_vae.MLXTAEHVDecoder

MLXTAEHVDecoder(checkpoint_path: Path, *, z_dim: int)

Minimal MLX port of TAEHV decoder (parallel-over-time MemBlocks).

Initialize the TAEHV decoder from a checkpoint for the specified latent dimensionality.

Parameters:

Name Type Description Default
checkpoint_path Path

Path to the TAEHV checkpoint.

required
z_dim int

Number of latent channels, determining the decoder patch size.

required
Source code in fastvideo/mlx_runtime/wan_vae.py
def __init__(self, checkpoint_path: Path, *, z_dim: int) -> None:
    """Initialize the TAEHV decoder from a checkpoint for the specified latent dimensionality.

    Parameters:
        checkpoint_path (Path): Path to the TAEHV checkpoint.
        z_dim (int): Number of latent channels, determining the decoder patch size.
    """
    import mlx.core as mx

    self.checkpoint_path = Path(checkpoint_path)
    self.latent_channels = z_dim
    # Derive patch_size from z_dim: 48 channels → patch_size=2, 16 → patch_size=1
    self.patch_size = 2 if z_dim == 48 else 1
    self.image_channels = 3
    self.frames_to_trim = 3  # TGrow strides (1,2,2) → 2**2 - 1 for w2.1/w2.2 defaults

    sd = _load_torch_state(self.checkpoint_path)
    # Patch TGrow kernels like upstream TAEHV.patch_tgrow_layers.
    self.weights = {k: mx.array(v) for k, v in sd.items()}
    self._n_f = [256, 128, 64, 64]

Methods:

fastvideo.mlx_runtime.wan_vae.MLXTAEHVDecoder.decode_ntchw
decode_ntchw(latents_ntchw: Any) -> Any

Decode latent video batches into clipped RGB frames.

Parameters:

Name Type Description Default
latents_ntchw Any

Latents with shape [N, T, C, H, W] and the decoder's configured latent channel count.

required

Returns:

Name Type Description
Any Any

Decoded frames with shape [N, T_out, 3, H_out, W_out] and values clipped to the range [0, 1].

Source code in fastvideo/mlx_runtime/wan_vae.py
def decode_ntchw(self, latents_ntchw: Any) -> Any:
    """
    Decode latent video batches into clipped RGB frames.

    Parameters:
        latents_ntchw (Any): Latents with shape ``[N, T, C, H, W]`` and the
            decoder's configured latent channel count.

    Returns:
        Any: Decoded frames with shape ``[N, T_out, 3, H_out, W_out]`` and values
            clipped to the range ``[0, 1]``.
    """
    import mlx.core as mx

    x = latents_ntchw
    n, t, c, h, w = x.shape
    if c != self.latent_channels:
        raise ValueError(f"expected C={self.latent_channels}, got {c}")
    x = x.reshape(n * t, c, h, w)
    x = self._run_decoder_parallel(x, n=n)
    # Pixel-shuffle if patch_size > 1: (NT, 3*p*p, H, W) -> (NT, 3, H*p, W*p)
    if self.patch_size > 1:
        p = self.patch_size
        nt, c_out, hh, ww = x.shape
        x = x.reshape(nt, self.image_channels, p, p, hh, ww)
        x = x.transpose(0, 1, 4, 2, 5, 3).reshape(nt, self.image_channels, hh * p, ww * p)
    _, c_out, hh, ww = x.shape
    t_out = x.shape[0] // n
    x = x.reshape(n, t_out, c_out, hh, ww)
    if self.frames_to_trim > 0 and t_out > self.frames_to_trim:
        x = x[:, self.frames_to_trim:]
    return mx.clip(x, 0.0, 1.0)

fastvideo.mlx_runtime.wan_vae.WanVAEConfigView dataclass

WanVAEConfigView(z_dim: int, latents_mean: tuple[float, ...], latents_std: tuple[float, ...], scale_factor_spatial: int = 8, scale_factor_temporal: int = 4, patch_size: int | None = None, vae_dir: Path | None = None)

Minimal config fields needed for denormalize + spatial scale.

Methods:

fastvideo.mlx_runtime.wan_vae.WanVAEConfigView.from_vae_dir classmethod
from_vae_dir(vae_dir: Path) -> WanVAEConfigView

Load Wan VAE configuration values from a directory.

Parameters:

Name Type Description Default
vae_dir Path

Directory containing the VAE config.json file.

required

Returns:

Name Type Description
WanVAEConfigView WanVAEConfigView

Configuration loaded from the VAE directory.

Source code in fastvideo/mlx_runtime/wan_vae.py
@classmethod
def from_vae_dir(cls, vae_dir: Path) -> WanVAEConfigView:
    """
    Load Wan VAE configuration values from a directory.

    Parameters:
        vae_dir (Path): Directory containing the VAE ``config.json`` file.

    Returns:
        WanVAEConfigView: Configuration loaded from the VAE directory.
    """
    cfg = json.loads((vae_dir / "config.json").read_text())
    return cls(
        z_dim=int(cfg["z_dim"]),
        latents_mean=tuple(float(x) for x in cfg["latents_mean"]),
        latents_std=tuple(float(x) for x in cfg["latents_std"]),
        scale_factor_spatial=int(cfg.get("scale_factor_spatial", 8)),
        scale_factor_temporal=int(cfg.get("scale_factor_temporal", 4)),
        patch_size=cfg.get("patch_size"),
        vae_dir=vae_dir,
    )

Functions:

fastvideo.mlx_runtime.wan_vae.decode_latents_taehv_mlx

decode_latents_taehv_mlx(latents_np: ndarray, *, z_dim: int | None = None, checkpoint_path: Path | None = None) -> ndarray

Decode latent representations with the MLX TAEHV decoder.

Parameters:

Name Type Description Default
latents_np ndarray

Latents arranged as [B, C, T, H, W].

required
z_dim int | None

Latent channel dimension used to select the decoder checkpoint.

None
checkpoint_path Path | None

Optional path to a TAEHV checkpoint.

None

Returns:

Type Description
ndarray

np.ndarray: Decoded pixels arranged as [B, T, H, W, 3] with values in [0, 1].

Raises:

Type Description
ValueError

If latents_np does not have five dimensions.

Source code in fastvideo/mlx_runtime/wan_vae.py
def decode_latents_taehv_mlx(
    latents_np: np.ndarray,
    *,
    z_dim: int | None = None,
    checkpoint_path: Path | None = None,
) -> np.ndarray:
    """
    Decode latent representations with the MLX TAEHV decoder.

    Parameters:
        latents_np (np.ndarray): Latents arranged as [B, C, T, H, W].
        z_dim (int | None): Latent channel dimension used to select the decoder checkpoint.
        checkpoint_path (Path | None): Optional path to a TAEHV checkpoint.

    Returns:
        np.ndarray: Decoded pixels arranged as [B, T, H, W, 3] with values in [0, 1].

    Raises:
        ValueError: If `latents_np` does not have five dimensions.
    """
    import mlx.core as mx

    if latents_np.ndim != 5:
        raise ValueError(f"expected [B,C,T,H,W], got {latents_np.shape}")
    c = latents_np.shape[1]
    z = z_dim if z_dim is not None else c
    ckpt = ensure_taehv_checkpoint(z_dim=z, checkpoint_path=checkpoint_path)
    dec = MLXTAEHVDecoder(ckpt, z_dim=z)
    # NTCHW
    x = mx.array(latents_np.transpose(0, 2, 1, 3, 4).astype(np.float32))
    out = dec.decode_ntchw(x)  # N T C H W
    mx.eval(out)
    arr = np.array(out)
    # B T H W C
    return arr.transpose(0, 1, 3, 4, 2)

fastvideo.mlx_runtime.wan_vae.decode_latents_to_video

decode_latents_to_video(latents_np: ndarray, output_path: Path, *, fps: int = 16, backend: DecodeBackend = 'taehv', vae_dir: Path | None = None, z_dim: int | None = None, taehv_checkpoint: Path | None = None, torch_device: str = 'auto') -> dict[str, Any]

Decode latent video frames and export them as an MP4 file.

Parameters:

Name Type Description Default
latents_np ndarray

Latent video representation to decode.

required
output_path Path

Destination path for the MP4 file.

required
fps int

Output video frame rate.

16
backend DecodeBackend

Decoder backend to use.

'taehv'
vae_dir Path | None

Directory containing the full Wan VAE when using the wan-vae backend.

None
z_dim int | None

Latent channel count for TAEHV decoding.

None
taehv_checkpoint Path | None

Optional TAEHV checkpoint path.

None
torch_device str

PyTorch device selection for PyTorch-based decoding.

'auto'

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Decode time in seconds, backend name, output path, frame

dict[str, Any]

count, and video resolution.

Raises:

Type Description
ValueError

If the full VAE backend lacks vae_dir or the backend is

Source code in fastvideo/mlx_runtime/wan_vae.py
def decode_latents_to_video(
    latents_np: np.ndarray,
    output_path: Path,
    *,
    fps: int = 16,
    backend: DecodeBackend = "taehv",
    vae_dir: Path | None = None,
    z_dim: int | None = None,
    taehv_checkpoint: Path | None = None,
    torch_device: str = "auto",
) -> dict[str, Any]:
    """Decode latent video frames and export them as an MP4 file.

    Parameters:
        latents_np (np.ndarray): Latent video representation to decode.
        output_path (Path): Destination path for the MP4 file.
        fps (int): Output video frame rate.
        backend (DecodeBackend): Decoder backend to use.
        vae_dir (Path | None): Directory containing the full Wan VAE when using
            the ``wan-vae`` backend.
        z_dim (int | None): Latent channel count for TAEHV decoding.
        taehv_checkpoint (Path | None): Optional TAEHV checkpoint path.
        torch_device (str): PyTorch device selection for PyTorch-based decoding.

    Returns:
        dict[str, Any]: Decode time in seconds, backend name, output path, frame
        count, and video resolution.

    Raises:
        ValueError: If the full VAE backend lacks ``vae_dir`` or the backend is
        unknown.
    """
    import time

    from diffusers.utils import export_to_video

    t0 = time.perf_counter()
    if backend in ("taehv", "taehv-torch"):
        c = latents_np.shape[1] if z_dim is None else z_dim
        if backend == "taehv":
            video = decode_latents_taehv_mlx(latents_np, z_dim=c, checkpoint_path=taehv_checkpoint)
        else:
            # torch TAEHV (regression / parity reference)
            import torch
            from fastvideo.third_party.taehv import TAEHV

            ckpt = ensure_taehv_checkpoint(z_dim=c, checkpoint_path=taehv_checkpoint)
            if torch_device == "auto":
                torch_device = "mps" if torch.backends.mps.is_available() else "cpu"
            dtype = torch.float16 if torch_device == "mps" else torch.float32
            model = TAEHV(str(ckpt)).to(device=torch_device, dtype=dtype).eval()
            lat = torch.from_numpy(latents_np).to(device=torch_device, dtype=dtype)
            with torch.no_grad():
                out = model.decode_video(lat.transpose(1, 2), parallel=True, show_progress_bar=False)
            video = out[0].permute(0, 2, 3, 1).float().cpu().numpy()[None, ...]
            # out is NTCHW -> need BTHWC; decode_video returns NTCHW for batch
            if video.ndim == 4:
                video = video[None]
    elif backend == "wan-vae":
        if vae_dir is None:
            raise ValueError("vae_dir required for wan-vae backend")
        video = decode_latents_wan_vae_torch(latents_np, vae_dir=vae_dir, device=torch_device)
    else:
        raise ValueError(f"unknown backend {backend}")

    decode_s = time.perf_counter() - t0
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    # export_to_video expects list/array of frames HxWxC
    frames = video[0]
    frames = np.clip(frames, 0.0, 1.0)
    export_to_video(frames, str(output_path), fps=fps)
    if backend == "taehv":
        cleanup_mlx()
    else:
        if backend == "taehv-torch":
            del model, lat, out
        cleanup_torch_mps()
    return {
        "decode_s": decode_s,
        "backend": backend,
        "output_path": str(output_path),
        "num_frames": int(frames.shape[0]),
        "resolution": f"{frames.shape[2]}x{frames.shape[1]}" if frames.ndim == 4 else None,
    }

fastvideo.mlx_runtime.wan_vae.decode_latents_wan_vae_torch

decode_latents_wan_vae_torch(latents_np: ndarray, *, vae_dir: Path, device: str = 'auto', dtype_name: str = 'fp16') -> ndarray

Full AutoencoderKLWan decode on torch (MPS/CPU) with mean/std denormalize.

Returns pixels [B, T, H, W, 3] float in [0, 1].

Source code in fastvideo/mlx_runtime/wan_vae.py
def decode_latents_wan_vae_torch(
    latents_np: np.ndarray,
    *,
    vae_dir: Path,
    device: str = "auto",
    dtype_name: str = "fp16",
) -> np.ndarray:
    """Full AutoencoderKLWan decode on torch (MPS/CPU) with mean/std denormalize.

    Returns pixels ``[B, T, H, W, 3]`` float in [0, 1].
    """
    import torch
    from diffusers import AutoencoderKLWan
    from diffusers.video_processor import VideoProcessor

    if device == "auto":
        device = "mps" if torch.backends.mps.is_available() else "cpu"
    dtype = torch.float16 if dtype_name == "fp16" and device == "mps" else torch.float32
    config = WanVAEConfigView.from_vae_dir(vae_dir)
    vae = AutoencoderKLWan.from_pretrained(vae_dir, torch_dtype=dtype, local_files_only=True).to(device)
    vae.eval()
    latents = torch.from_numpy(latents_np.astype(np.float32)).to(device=device, dtype=dtype)
    mean = torch.tensor(config.latents_mean, device=device, dtype=dtype).view(1, -1, 1, 1, 1)
    inv_std = (1.0 / torch.tensor(config.latents_std, device=device, dtype=dtype)).view(1, -1, 1, 1, 1)
    latents = latents / inv_std + mean  # matches prompt_to_video path
    with torch.no_grad():
        video = vae.decode(latents, return_dict=False)[0]
    video = VideoProcessor(vae_scale_factor=config.scale_factor_spatial).postprocess_video(video, output_type="np")
    return video  # [B, T, H, W, 3]

fastvideo.mlx_runtime.wan_vae.denormalize_latents_np

denormalize_latents_np(latents: ndarray, config: WanVAEConfigView) -> ndarray

Denormalize Wan VAE latent values using the configured means and standard deviations.

Parameters:

Name Type Description Default
latents ndarray

Latent values in normalized form.

required
config WanVAEConfigView

Wan VAE latent statistics.

required

Returns:

Type Description
ndarray

np.ndarray: Denormalized latent values as float32.

Source code in fastvideo/mlx_runtime/wan_vae.py
def denormalize_latents_np(latents: np.ndarray, config: WanVAEConfigView) -> np.ndarray:
    """
    Denormalize Wan VAE latent values using the configured means and standard deviations.

    Parameters:
        latents (np.ndarray): Latent values in normalized form.
        config (WanVAEConfigView): Wan VAE latent statistics.

    Returns:
        np.ndarray: Denormalized latent values as float32.
    """
    mean = np.asarray(config.latents_mean, dtype=np.float32).reshape(1, -1, 1, 1, 1)
    std = np.asarray(config.latents_std, dtype=np.float32).reshape(1, -1, 1, 1, 1)
    return latents.astype(np.float32) * std + mean

fastvideo.mlx_runtime.wan_vae.ensure_taehv_checkpoint

ensure_taehv_checkpoint(*, z_dim: int, checkpoint_path: Path | None = None) -> Path

Return a validated TAEHV checkpoint for the specified latent channel count.

Parameters:

Name Type Description Default
z_dim int

Number of latent channels, supported values are 16 and 48.

required
checkpoint_path Path | None

Optional existing checkpoint path to validate and use.

None

Returns:

Name Type Description
Path Path

Path to the validated TAEHV checkpoint.

Raises:

Type Description
FileNotFoundError

If the supplied checkpoint path does not exist.

ValueError

If no checkpoint is mapped to the specified latent channel count.

Source code in fastvideo/mlx_runtime/wan_vae.py
def ensure_taehv_checkpoint(*, z_dim: int, checkpoint_path: Path | None = None) -> Path:
    """
    Return a validated TAEHV checkpoint for the specified latent channel count.

    Parameters:
        z_dim (int): Number of latent channels, supported values are 16 and 48.
        checkpoint_path (Path | None): Optional existing checkpoint path to validate and use.

    Returns:
        Path: Path to the validated TAEHV checkpoint.

    Raises:
        FileNotFoundError: If the supplied checkpoint path does not exist.
        ValueError: If no checkpoint is mapped to the specified latent channel count.
    """
    if checkpoint_path is not None:
        if not checkpoint_path.exists():
            raise FileNotFoundError(f"TAEHV checkpoint not found: {checkpoint_path}")
        return checkpoint_path
    if z_dim == 16:
        name, url, expect = "taew2_1.pth", TAEW2_1_URL, TAEW2_1_SHA256
    elif z_dim == 48:
        name, url, expect = "taew2_2.pth", TAEW2_2_URL, TAEW2_2_SHA256
    else:
        raise ValueError(f"No TAEHV checkpoint mapped for z_dim={z_dim} (supported: 16, 48)")
    path = _cache_dir() / name
    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        print(f"Downloading {url} -> {path}")
        import socket
        import tempfile
        # Download to a temporary file, verify, then atomically rename.
        with tempfile.NamedTemporaryFile(
                mode="wb",
                dir=path.parent,
                prefix=f".tmp_{name}_",
                suffix=".pth",
                delete=False,
        ) as tmp_file:
            tmp_path = Path(tmp_file.name)
            try:
                old_timeout = socket.getdefaulttimeout()
                socket.setdefaulttimeout(300)
                try:
                    urllib.request.urlretrieve(url, tmp_path)  # noqa: S310 - public pinned artifact.
                finally:
                    socket.setdefaulttimeout(old_timeout)
                _verify_checkpoint(tmp_path, expect)
                tmp_path.replace(path)
            except Exception:
                tmp_path.unlink(missing_ok=True)
                raise
    else:
        _verify_checkpoint(path, expect)
    return path