[feat] Add an optional MLX TAEH3 preview decoder (#1794)

Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
This commit is contained in:
Aryan Kumar
2026-08-31 05:08:06 -07:00
committed by GitHub
co-authored by Aryan Kumar
parent f82d8be4bf
commit a28f2bab4b
9 changed files with 642 additions and 3 deletions
+157
View File
@@ -0,0 +1,157 @@
# Decode H3 previews with TAEH3 on MLX
TAEH3 is an optional tiny video decoder for MiniMax H3. It replaces only
video reconstruction. The denoiser, sampler, resolution, frame count, and
audio decoder stay unchanged. The full H3 VAE remains the default.
TAEH3 produces a different reconstruction. Fine fur, fabric, vegetation,
and surface textures can look softer. Use it for previews or when you accept
that tradeoff. It is not a lossless acceleration of the full VAE.
## Generate a video
Use your existing MLX FastH3 environment and converted checkpoint:
```bash
python examples/inference/basic/mlx_fasth3.py \
--model-root ~/models/FastH3-Preview-v0.2 \
--mlx-checkpoint ~/models/FastH3-MLX/int6 \
--prompt 'A red panda beside a mountain lake at sunrise.' \
--height 480 --width 832 --num-frames 124 --steps 4 --seed 2027 \
--video-decode-backend taeh3 --vae-dtype fp32 \
--output-path video_samples/taeh3_preview.mp4
```
The first run downloads a 22.7 MB safetensors checkpoint from an immutable
upstream revision and verifies its SHA-256 digest. No remote Python code runs.
The cache is `~/.cache/fastvideo/taehv/taeh3.safetensors`.
Use `--taeh3-checkpoint /path/to/taeh3.safetensors` for offline use or a custom
trained checkpoint. Custom files must match the decoder architecture; they
are not required to match the upstream digest.
| Option | Default | Behavior |
| --- | --- | --- |
| `--video-decode-backend` | `h3-vae` | Select `taeh3` for approximate decoding. |
| `--taeh3-checkpoint` | Unset | Use the pinned upstream checkpoint from cache. |
| `--taeh3-chunk-size` | `5` | Latent frames per execution chunk. Smaller chunks reduce feature memory. |
| `--vae-dtype` | `fp32` | Decoder computation dtype. FP16 and BF16 are separate numerical tradeoffs. |
`--tiled-video-decode` controls the full VAE only. TAEH3 uses the whole spatial
canvas and bounded temporal chunks. Its memory blocks carry state across
chunks. The pipeline reports `video_decode_backend`, decode timing, and MLX
peak memory alongside the existing generation metrics.
The mode composes with `--fast-spatial` and temporal `--fast`. Those options
change the denoising workload and have additional quality costs. A TAEH3-only
measurement does not establish the quality or speed of a combined mode.
## Latent contract
The native decoder reads normalized diffusion latents in NCTHW layout through
`decode_latents_taeh3_mlx`. Do not apply the full VAE's mean, standard deviation,
or pixel denormalization. Its 24 latent channels reconstruct RGB at 16 times
the latent spatial dimensions.
H3 uses latent lengths `5*k-3`, such as 2, 7, and 37. TAEH3 removes three raw
frames from each group of 20 decoder outputs. Thus 37 latent frames produce
124 RGB frames. The port validates that contract before returning output.
## Provenance and validation
Architecture and weights come from Ollin Boer Bohan's MIT-licensed
[TAEHV H3 implementation](https://github.com/madebyollin/taehv/commit/62f7591f59dfbb4c3c02b7a621d180a9eeaba26c).
The [Aryan fork](https://github.com/aryan5v/taehv/tree/aryan/first-class-taeh3)
adds an explicit `TAEH3` API and checkpoint-loading tests. The fork is not an
official MiniMax release and does not contain newly trained H3 weights.
Run the numerical tests against a local TAEHV checkout containing the released
weights:
```bash
TAEH3_REFERENCE_DIR=/path/to/taehv \
python -m pytest fastvideo/tests/mlx/test_mlx_taeh3.py -q
```
Tests compare MLX FP32 with upstream sequential FP32 and parallel FP64 at
`atol=1e-5, rtol=1e-5`. The initial parallel CPU FP32 comparison failed that
strict gate, reaching about `4e-5` maximum error on a 37-latent small fixture.
CPU convolution rounding changes with its batch size. The FP64 reference
and sequential FP32 checks distinguish this from a temporal chunking error.
The original failed comparison is not reported as a pass.
Passing these tests means the MLX port agrees with the tiny decoder within
the specified tolerance. It does not mean TAEH3 matches the full H3 VAE.
## Compare decoders without another denoising run
Save the normalized packed `video_rows` returned by `pipeline.denoise` as
`np.savez("latents.npz", video=video_rows)`. Then run:
```bash
python examples/inference/basic/mlx_h3_decode_benchmark.py \
--latents latents.npz \
--model-root ~/models/FastH3-Preview-v0.2 \
--mlx-checkpoint ~/models/FastH3-MLX/int6 \
--height 480 --width 832 --num-frames 124 \
--output-dir outputs/taeh3_comparison
```
Use a fresh output directory. The benchmark writes the first decoded frame
arrays and a JSON report with the input digest, MLX version, device, per-trial
latency, MLX peak active memory, lifetime process peak RSS, and swap snapshots.
Decoder loading is included; first-time checkpoint downloading is excluded.
Only run one MLX workload at a time. `--repeats` reverses the decoder order on
alternate trials. Do not treat two memory counters as additive or infer zero
page-outs from unchanged swap snapshots.
## Measured decoder results
On an Apple M4 Max with 36 GB unified memory, MLX 0.32.2, FP32 decoding,
and five-latent execution chunks:
| Workload | Full H3 VAE | TAEH3 |
| --- | --- | --- |
| Saved 37-frame latents to 124 RGB frames, 832x480 | 107.90 s | 1.44 s |
| MLX peak active memory for that decode | 11.03 GiB | 3.62 GiB |
These are one matched pair using the same seed-2027 production latent file,
including decoder loading. Both swap snapshots stayed unchanged. The decoded
images differ: PSNR against the full VAE was 29.86 dB, and inspected frames
showed softer fine detail. The approximately 75x ratio applies only to this
decoder comparison, not the entire generation pipeline.
Eight additional TAEH3-only decodes measured 0.96 s on first use and a 0.98 s median across seven warm trials, ranging from 0.96 to 0.99 s. Decoder construction and weight loading were included in each trial. The full VAE was not repeated eight times.
### Native resolution with temporal fast
A separate uncached run with `--fast --video-decode-backend taeh3`, without
spatial fast, completed in **205.47 s wall time**. It kept the native 832x480
canvas, denoised 73 source frames, and used RIFE to produce 124 output frames
with full-duration audio. Seed 2027, four steps, dense attention, INT6.
Prompt encoding took 16.66 s, denoising 181.14 s, TAEH3 decoding 0.64 s, RIFE
5.23 s, audio decoding 0.73 s, and muxing 0.40 s. Peak denoise MLX allocation
was 17.87 GiB. System swap use rose from 1231.19 to 2753.75 MiB.
The output has 124 H.264 frames at 832x480 and stereo AAC at 32 kHz.
This is one combined-mode measurement. It preserves spatial resolution but
still combines frame interpolation with approximate decoding. Frame samples
retain more fine detail than the spatial fast experiment below. Motion and
speech need human review; do not infer native dense generation parity.
### Spatial fast experiment, not the preferred quality path
A separate uncached generation with `--fast-spatial --video-decode-backend
taeh3` produced a 124-frame, 832x480 MP4 with stereo audio in **95.75 s wall
time**. Seed 2028, four denoising calls, INT6 weights, dense attention, and no
temporal fast mode were used. The internal canvas was 416x256, then cropped
and upscaled to the requested output size.
That run spent 16.66 s encoding the prompt, 77.05 s denoising, 0.34 s decoding
video, 0.30 s upscaling, 0.73 s decoding audio, and 0.40 s muxing. Peak denoise
MLX allocation was 16.85 GiB. System swap use rose from 1041.94 to 1247.19 MiB;
this measurement does not attribute that increase to a particular process.
It is one end-to-end result, not a repeated benchmark or native-resolution
quality comparison. The reduced canvas visibly loses detail, especially in
the opening frames. Speech intelligibility and motion quality need human
review before treating this combination as a final-output preset.
+10
View File
@@ -98,6 +98,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--rife-weights-dir", type=Path, default=None,
help="optional local mlx-community/RIFE-4.25 snapshot")
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
parser.add_argument("--video-decode-backend", choices=("h3-vae", "taeh3"), default="h3-vae",
help="full H3 VAE or approximate TAEH3 preview decoder; audio is unchanged")
parser.add_argument("--taeh3-checkpoint", type=Path, default=None,
help="local TAEH3 safetensors; otherwise download hash-verified upstream weights")
parser.add_argument("--taeh3-chunk-size", type=int, default=5,
help="latent frames per TAEH3 chunk; causal memory persists across chunks")
parser.add_argument("--prompt-cache-dir", type=Path, default=None,
help="directory for reusable prompt embedding caches")
parser.add_argument(
@@ -169,6 +175,9 @@ def main() -> None:
model_root=args.model_root,
mlx_dit_checkpoint=args.mlx_checkpoint,
vae_dtype=args.vae_dtype,
video_decode_backend=args.video_decode_backend,
taeh3_checkpoint=args.taeh3_checkpoint,
taeh3_chunk_size=args.taeh3_chunk_size,
prompt_cache_dir=args.prompt_cache_dir,
)
result = pipeline.generate(
@@ -201,6 +210,7 @@ def main() -> None:
"timings_s": {k: round(v, 2) for k, v in result.timings.items()},
"peak_memory_gib": {k: round(v, 2) for k, v in result.peak_memory_gib.items()},
"vsa": result.vsa,
"video_decode_backend": result.video_decode_backend,
"audio_samples": int(result.waveform.shape[-1]),
}, indent=2))
@@ -0,0 +1,104 @@
# SPDX-License-Identifier: Apache-2.0
"""Compare H3 decoders on saved normalized video rows without rerunning denoise.
The input NPZ must contain a ``video`` array of packed diffusion rows. Geometry
and the DiT manifest must match the generation that produced those rows.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import platform
import resource
import subprocess
import time
from pathlib import Path
import numpy as np
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--latents", type=Path, required=True)
parser.add_argument("--model-root", type=Path, required=True)
parser.add_argument("--mlx-checkpoint", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--height", type=int, default=480)
parser.add_argument("--width", type=int, default=832)
parser.add_argument("--num-frames", type=int, default=124)
parser.add_argument("--backends", nargs="+", choices=("h3-vae", "taeh3"), default=["h3-vae", "taeh3"])
parser.add_argument("--taeh3-checkpoint", type=Path)
parser.add_argument("--taeh3-chunk-size", type=int, default=5)
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
parser.add_argument("--repeats", type=int, default=1)
args = parser.parse_args()
if args.repeats < 1:
parser.error("--repeats must be positive")
if args.output_dir.exists():
parser.error("--output-dir must be a new directory to preserve previous results")
import mlx.core as mx
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline, _cleanup_mlx
from fastvideo.mlx_runtime.minimax_h3_taeh3 import ensure_taeh3_checkpoint
with np.load(args.latents) as archive:
rows = archive["video"]
if not np.isfinite(rows).all():
raise ValueError("The saved video rows contain non-finite values")
args.output_dir.mkdir(parents=True)
checksum = hashlib.sha256()
with args.latents.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
checksum.update(chunk)
checkpoint = ensure_taeh3_checkpoint(args.taeh3_checkpoint) if "taeh3" in args.backends else None
report = {
"mlx": mx.__version__,
"platform": platform.platform(),
"device": mx.device_info(),
"latents": str(args.latents.resolve()),
"latents_sha256": checksum.hexdigest(),
"geometry": [args.height, args.width, args.num_frames],
"dtype": args.vae_dtype,
"chunk_size": args.taeh3_chunk_size,
"checkpoint_download_excluded": True,
"decoder_loading_included": True,
"trials": [],
}
for repeat in range(args.repeats):
# Reverse each paired trial's order to expose warmup/order effects.
order = args.backends if repeat % 2 == 0 else args.backends[::-1]
for backend in order:
pipeline = MiniMaxH3MLXPipeline(model_root=args.model_root,
mlx_dit_checkpoint=args.mlx_checkpoint,
video_decode_backend=backend,
taeh3_checkpoint=checkpoint if backend == "taeh3" else None,
taeh3_chunk_size=args.taeh3_chunk_size,
vae_dtype=args.vae_dtype)
_cleanup_mlx()
mx.reset_peak_memory()
before = subprocess.check_output(["sysctl", "-n", "vm.swapusage"], text=True).strip()
started = time.perf_counter()
frames = pipeline.decode_video(rows, height=args.height, width=args.width, num_frames=args.num_frames)
elapsed = time.perf_counter() - started
trial = {
"repeat": repeat,
"backend": backend,
"decode_s": elapsed,
"shape": list(frames.shape),
"peak_active_gib": mx.get_peak_memory() / 2**30,
"process_lifetime_rss_peak_gib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 2**30,
"swap_before": before,
"swap_after": subprocess.check_output(["sysctl", "-n", "vm.swapusage"], text=True).strip(),
}
if repeat == 0:
np.save(args.output_dir / f"{backend}_frames.npy", frames)
report["trials"].append(trial)
(args.output_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(trial), flush=True)
del frames, pipeline
_cleanup_mlx()
if __name__ == "__main__":
main()
+41 -3
View File
@@ -71,6 +71,7 @@ class GenerationResult:
timings: dict[str, float] = field(default_factory=dict)
peak_memory_gib: dict[str, float] = field(default_factory=dict)
vsa: dict[str, Any] = field(default_factory=dict)
video_decode_backend: str = "h3-vae"
@dataclass(frozen=True)
@@ -305,6 +306,9 @@ class MiniMaxH3MLXPipeline:
conditioner_dir: str | Path | None = None,
tokenizer_dir: str | Path | None = None,
metal_wired_limit_gib: float | None = None,
video_decode_backend: str = "h3-vae",
taeh3_checkpoint: str | Path | None = None,
taeh3_chunk_size: int = 5,
) -> None:
import mlx.core as mx
@@ -322,6 +326,15 @@ class MiniMaxH3MLXPipeline:
self.model_root = Path(model_root)
self.dit_checkpoint = Path(mlx_dit_checkpoint)
self.vae_dtype = vae_dtype
if video_decode_backend not in ("h3-vae", "taeh3"):
raise ValueError(f"Unknown H3 video decoder: {video_decode_backend}")
if taeh3_chunk_size < 1:
raise ValueError("taeh3_chunk_size must be positive.")
if taeh3_checkpoint is not None and video_decode_backend != "taeh3":
raise ValueError("taeh3_checkpoint requires video_decode_backend='taeh3'.")
self.video_decode_backend = video_decode_backend
self.taeh3_checkpoint = taeh3_checkpoint
self.taeh3_chunk_size = taeh3_chunk_size
self.prompt_cache_dir = Path(prompt_cache_dir) if prompt_cache_dir else None
self.conditioner_dir = Path(conditioner_dir) if conditioner_dir else self.model_root / "text_encoder"
self.tokenizer_dir = Path(tokenizer_dir) if tokenizer_dir else self.model_root / "tokenizer"
@@ -344,7 +357,7 @@ class MiniMaxH3MLXPipeline:
missing.append(str(self.dit_checkpoint))
vae_dir = self.model_root / "vae"
audio_dir = self.model_root / "audio_vae"
if not (vae_dir.exists() and any(vae_dir.glob("*.safetensors"))):
if self.video_decode_backend == "h3-vae" and not (vae_dir.exists() and any(vae_dir.glob("*.safetensors"))):
missing.append(str(vae_dir))
if not (audio_dir.exists() and any(audio_dir.glob("*.safetensors"))):
missing.append(str(audio_dir))
@@ -562,6 +575,22 @@ class MiniMaxH3MLXPipeline:
from fastvideo.mlx_runtime.minimax_h3_video_vae import mlx_h3_video_vae_from_dir
geometry = self.resolve_geometry(height, width, num_frames, enforce_duration=False)
if self.video_decode_backend == "taeh3":
from fastvideo.mlx_runtime.minimax_h3_taeh3 import decode_latents_taeh3_mlx
if self._dit_in_channels != 24:
raise ValueError("TAEH3 requires a 24-channel H3 checkpoint.")
latents = unpatchify_video_tokens(video_rows, geometry["latent_frame_count"], geometry["latent_height"],
geometry["latent_width"], self._dit_in_channels, self._dit_patch_size)
pixels = decode_latents_taeh3_mlx(latents,
checkpoint_path=self.taeh3_checkpoint,
dtype=self.vae_dtype,
chunk_size=self.taeh3_chunk_size)
frames = (pixels[0] * 255.0).astype(np.uint8)
if frames.shape != (geometry["num_frames"], height, width, 3):
raise RuntimeError(f"TAEH3 produced unexpected frame shape: {frames.shape}")
_cleanup_mlx()
return frames
vae = mlx_h3_video_vae_from_dir(self.model_root / "vae", include_encoder=False, storage_dtype=self.vae_dtype)
expected_height = height // vae.spatial_compression_ratio
expected_width = width // vae.spatial_compression_ratio
@@ -775,6 +804,14 @@ class MiniMaxH3MLXPipeline:
spatial_plan,
)
if self.video_decode_backend == "taeh3":
from fastvideo.mlx_runtime.minimax_h3_taeh3 import ensure_taeh3_checkpoint
started = time.perf_counter()
ensure_taeh3_checkpoint(self.taeh3_checkpoint)
timings["decoder_prepare_s"] = time.perf_counter() - started
logger.warning("TAEH3 is an approximate preview decoder; reconstruction differs from the full H3 VAE.")
_reset_peak_memory()
started = time.perf_counter()
text_rows, token_tags = self.encode_prompt(prompt)
@@ -872,8 +909,8 @@ class MiniMaxH3MLXPipeline:
video_path = self.mux(frames, waveform, output_path)
timings["mux_s"] = time.perf_counter() - started
timings["generate_s"] = sum(
timings.get(key, 0.0) for key in ("condition_s", "denoise_s", "video_decode_s", "rife_s",
"spatial_upsample_s", "audio_decode_s", "mux_s"))
timings.get(key, 0.0) for key in ("decoder_prepare_s", "condition_s", "denoise_s", "video_decode_s",
"rife_s", "spatial_upsample_s", "audio_decode_s", "mux_s"))
result = GenerationResult(
video_path=str(video_path),
@@ -882,6 +919,7 @@ class MiniMaxH3MLXPipeline:
sample_rate=32000,
timings=timings,
peak_memory_gib=peaks,
video_decode_backend=self.video_decode_backend,
vsa=getattr(self, "last_vsa_stats", None) or {
"enabled": vsa_config.enabled,
"checkpoint_vsa_capable": mlx_h3_checkpoint_vsa_capable(self.dit_checkpoint),
+195
View File
@@ -0,0 +1,195 @@
# SPDX-License-Identifier: Apache-2.0
"""Optional, approximate MiniMax H3 tiny decoder, executed entirely in MLX.
Architecture and temporal mapping adapted from madebyollin/taehv at
62f7591f59dfbb4c3c02b7a621d180a9eeaba26c (MIT, Ollin Boer Bohan).
See fastvideo/third_party/taehv/LICENSE. Weights remain in the upstream format.
This decoder consumes normalized diffusion latents; it does not use the full
H3 VAE's latent mean/std or pixel denormalization.
"""
from __future__ import annotations
import hashlib
import tempfile
import urllib.request
from pathlib import Path
from typing import Any
import numpy as np
TAEH3_REVISION = "62f7591f59dfbb4c3c02b7a621d180a9eeaba26c"
TAEH3_URL = f"https://raw.githubusercontent.com/madebyollin/taehv/{TAEH3_REVISION}/safetensors/taeh3.safetensors"
TAEH3_SHA256 = "4fd022bfcab08772fe0536b17ea1a3bbb5625be11e397868d1c5d891863d4c13"
def ensure_taeh3_checkpoint(checkpoint_path: str | Path | None = None) -> Path:
"""Fetch only pinned weights, atomically; never download executable code.
Explicit local safetensors paths may contain custom trained weights.
Managed cache entries must match the upstream SHA-256 digest.
"""
if checkpoint_path is not None:
path = Path(checkpoint_path).expanduser()
if not path.is_file():
raise FileNotFoundError(f"TAEH3 checkpoint not found: {path}")
if path.suffix != ".safetensors":
raise ValueError("The MLX TAEH3 decoder requires a .safetensors checkpoint.")
return path
path = Path.home() / ".cache/fastvideo/taehv/taeh3.safetensors"
def verify(candidate: Path) -> None:
with candidate.open("rb") as handle:
hasher = hashlib.sha256()
for chunk in iter(lambda: handle.read(1 << 20), b""):
hasher.update(chunk)
digest = hasher.hexdigest()
if digest != TAEH3_SHA256:
raise RuntimeError(f"TAEH3 checkpoint failed SHA-256 verification: {candidate}")
if path.exists():
verify(path)
return path
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".safetensors", delete=False) as temporary_file:
temporary = Path(temporary_file.name)
try:
with urllib.request.urlopen(TAEH3_URL, timeout=60) as response, temporary.open("wb") as handle:
while chunk := response.read(1 << 20):
handle.write(chunk)
verify(temporary)
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
return path
class MLXTAEH3Decoder:
"""Decode H3 NTCHW latents with bounded temporal feature memory.
Chunks carry the last input of every MemBlock into the next chunk.
Chunking limits activation memory without resetting the causal state.
The full H3 VAE remains the quality reference, not a numerical oracle for
this independently trained tiny decoder.
"""
def __init__(self, checkpoint_path: str | Path, *, dtype: str = "fp32") -> None:
import mlx.core as mx
if dtype not in ("fp32", "fp16", "bf16"):
raise ValueError(f"Unsupported TAEH3 dtype: {dtype}")
self.dtype = {"fp32": mx.float32, "fp16": mx.float16, "bf16": mx.bfloat16}[dtype]
raw = mx.load(str(checkpoint_path))
expected: dict[str, tuple[int, ...]] = {
"decoder.1.weight": (256, 24, 3, 3),
"decoder.1.bias": (256, ),
"decoder.7.conv.weight": (256, 256, 1, 1),
"decoder.8.weight": (128, 256, 3, 3),
"decoder.13.conv.weight": (256, 128, 1, 1),
"decoder.14.weight": (64, 128, 3, 3),
"decoder.19.conv.weight": (128, 64, 1, 1),
"decoder.20.weight": (64, 64, 3, 3),
"decoder.22.weight": (12, 64, 3, 3),
"decoder.22.bias": (12, ),
}
for index, channels in ((3, 256), (4, 256), (5, 256), (9, 128), (10, 128), (11, 128), (15, 64), (16, 64), (17,
64)):
for layer in (0, 2, 4):
prefix = f"decoder.{index}.conv.{layer}"
expected[f"{prefix}.weight"] = (channels, channels * (2 if layer == 0 else 1), 3, 3)
expected[f"{prefix}.bias"] = (channels, )
actual = {key for key in raw if key.startswith("decoder.")}
if actual != set(expected):
raise ValueError(f"TAEH3 decoder keys mismatch: missing={set(expected) - actual}, "
f"unexpected={actual - set(expected)}")
self.weights = {}
for key, shape in expected.items():
value = raw[key]
if tuple(value.shape) != shape:
raise ValueError(f"TAEH3 weight {key} has shape {value.shape}, expected {shape}")
# Keep convolutions and features channels-last throughout execution.
value = value.transpose(0, 2, 3, 1) if value.ndim == 4 else value
self.weights[key] = mx.contiguous(value.astype(self.dtype))
mx.eval(*self.weights.values())
def _conv(self, x: Any, name: str) -> Any:
import mlx.core as mx
weight = self.weights[f"{name}.weight"]
y = mx.conv2d(x, weight, padding=weight.shape[1] // 2)
bias = self.weights.get(f"{name}.bias")
return y if bias is None else y + bias
def _chunk(self, x: Any, memory: dict[int, Any]) -> Any:
import mlx.core as mx
n, t, h, w, c = x.shape
x = mx.maximum(self._conv(mx.tanh(x.reshape(n * t, h, w, c) / 3.0) * 3.0, "decoder.1"), 0)
for indices, grow, projection, stride in (((3, 4, 5), 7, 8, 1), ((9, 10, 11), 13, 14, 2), ((15, 16, 17), 19, 20,
2)):
for index in indices:
_, h, w, c = x.shape
sequence = x.reshape(n, -1, h, w, c)
previous = memory.get(index)
if previous is None:
previous = mx.zeros_like(sequence[:, :1])
past = mx.concatenate([previous, sequence[:, :-1]], axis=1).reshape(x.shape)
# Preserve the final feature frame for the next execution chunk.
memory[index] = mx.contiguous(sequence[:, -1:])
y = mx.concatenate([x, past], axis=-1)
for layer in (0, 2, 4):
y = self._conv(y, f"decoder.{index}.conv.{layer}")
if layer != 4:
y = mx.maximum(y, 0)
x = mx.maximum(x + y, 0)
nt, h, w, c = x.shape
x = mx.broadcast_to(x.reshape(nt, h, 1, w, 1, c), (nt, h, 2, w, 2, c))
x = x.reshape(nt, h * 2, w * 2, c)
x = self._conv(x, f"decoder.{grow}.conv")
# Torch TGrow splits its channel-major output into temporal frames.
x = x.reshape(nt, h * 2, w * 2, stride, c).transpose(0, 3, 1, 2, 4)
x = self._conv(x.reshape(nt * stride, h * 2, w * 2, c), f"decoder.{projection}")
x = self._conv(mx.maximum(x, 0), "decoder.22")
nt, h, w, _ = x.shape
# Torch pixel_shuffle stores channels as (RGB, patch_y, patch_x).
x = x.reshape(nt, h, w, 3, 2, 2).transpose(0, 1, 4, 2, 5, 3)
return mx.clip(x.reshape(n, -1, h * 2, w * 2, 3), 0, 1)
def decode_ntchw(self, latents: Any, *, chunk_size: int = 5) -> Any:
"""Return NTCHW RGB in [0, 1] for H3's valid 5*k-3 latent lengths."""
import mlx.core as mx
if latents.ndim != 5 or latents.shape[2] != 24 or min(latents.shape) <= 0:
raise ValueError(f"Expected nonempty NTCHW H3 latents with 24 channels, got {latents.shape}")
if latents.shape[1] % 5 != 2:
raise ValueError("H3 latent time must be 5*k-3, for example 2, 7, or 37.")
if chunk_size < 1:
raise ValueError("TAEH3 chunk_size must be positive.")
x = latents.astype(self.dtype).transpose(0, 1, 3, 4, 2)
memory: dict[int, Any] = {}
frames = []
for start in range(0, x.shape[1], chunk_size):
decoded = self._chunk(x[:, start:start + chunk_size], memory)
# Remove three raw frames from each five-latent (20-frame) group.
keep = [i for i in range(decoded.shape[1]) if (start * 4 + i) % 20 >= 3]
decoded = decoded[:, mx.array(keep, dtype=mx.int32)]
mx.eval(decoded, *memory.values())
frames.append(decoded)
return mx.concatenate(frames, axis=1).transpose(0, 1, 4, 2, 3)
def decode_latents_taeh3_mlx(latents: np.ndarray,
*,
checkpoint_path: str | Path | None = None,
dtype: str = "fp32",
chunk_size: int = 5) -> np.ndarray:
"""Decode normalized NCTHW diffusion latents into NTHWC float RGB."""
import mlx.core as mx
if latents.ndim != 5:
raise ValueError(f"Expected NCTHW latents, got {latents.shape}")
checkpoint = ensure_taeh3_checkpoint(checkpoint_path)
decoder = MLXTAEH3Decoder(checkpoint, dtype=dtype)
output = decoder.decode_ntchw(mx.array(latents.transpose(0, 2, 1, 3, 4)), chunk_size=chunk_size)
mx.eval(output)
return np.asarray(output.astype(mx.float32)).transpose(0, 1, 3, 4, 2)
@@ -119,6 +119,7 @@ def test_default_wired_limit_scales_down_and_keeps_tested_cap() -> None:
def test_mux_cleans_temporary_files_after_ffmpeg_failure(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
pipeline = MiniMaxH3MLXPipeline.__new__(MiniMaxH3MLXPipeline)
pipeline.video_decode_backend = "h3-vae"
output = tmp_path / "output.mp4"
frames = np.zeros((1, 2, 2, 3), dtype=np.uint8)
waveform = np.zeros((2, 32), dtype=np.float32)
@@ -91,6 +91,7 @@ def _generate_with_mocked_phases(monkeypatch, tmp_path, **generate_kwargs):
calls: dict = {}
pipeline = MiniMaxH3MLXPipeline.__new__(MiniMaxH3MLXPipeline)
pipeline.video_decode_backend = "h3-vae"
pipeline.dit_checkpoint = tmp_path
monkeypatch.setattr("fastvideo.mlx_runtime.minimax_h3_pipeline._validate_checkpoint_step_ladder",
@@ -163,6 +163,7 @@ def test_dense_generate_ignores_unused_vsa_parameters(tmp_path, monkeypatch):
from fastvideo.mlx_runtime import minimax_h3_pipeline as pipeline_mod
pipeline = object.__new__(pipeline_mod.MiniMaxH3MLXPipeline)
pipeline.video_decode_backend = "h3-vae"
pipeline.dit_checkpoint = tmp_path
monkeypatch.setattr(pipeline_mod, "_validate_checkpoint_step_ladder", lambda *a: None)
def stop_at_media_preflight(**kwargs):
+132
View File
@@ -0,0 +1,132 @@
# SPDX-License-Identifier: Apache-2.0
"""TAEH3 port parity. Requires Apple MLX and an explicit upstream checkout.
TAEH3_REFERENCE_DIR=/path/to/taehv python -m pytest fastvideo/tests/mlx/test_mlx_taeh3.py
No weights or source code are downloaded by this test.
"""
import importlib.util
import os
from pathlib import Path
import numpy as np
import pytest
mx = pytest.importorskip('mlx.core')
from fastvideo.mlx_runtime.minimax_h3_taeh3 import MLXTAEH3Decoder, ensure_taeh3_checkpoint
@pytest.fixture(scope='module')
def models():
torch = pytest.importorskip('torch')
location = os.environ.get('TAEH3_REFERENCE_DIR')
if not location:
pytest.skip('Set TAEH3_REFERENCE_DIR to an upstream taehv checkout')
root = Path(location)
spec = importlib.util.spec_from_file_location('taeh3_reference', root / 'taehv.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# FP64 avoids the CPU FP32 convolution's batch-size-dependent rounding.
# The original FP32 comparison is recorded separately in the validation report.
reference = module.TAEHV(str(root / 'taeh3.pth')).double().eval()
actual = MLXTAEH3Decoder(root / 'safetensors/taeh3.safetensors')
return torch, reference, actual
@pytest.mark.parametrize('reference_mode', ['serial_fp32', 'parallel_fp64'])
@pytest.mark.parametrize('batch,time,chunk', [(1, 2, 5), (1, 7, 1), (2, 7, 3), (1, 12, 5), (1, 37, 5)])
def test_mlx_taeh3_matches_upstream(models, batch, time, chunk, reference_mode):
torch, reference, actual = models
latent = np.random.default_rng(123).standard_normal((batch, time, 24, 2, 3)).astype(np.float32)
with torch.no_grad():
dtype = torch.float64 if reference_mode == "parallel_fp64" else torch.float32
reference = reference.to(dtype=dtype)
expected = reference.decode_video(torch.from_numpy(latent).to(dtype=dtype),
parallel=reference_mode == "parallel_fp64",
show_progress_bar=False).float().numpy()
output = np.asarray(actual.decode_ntchw(mx.array(latent), chunk_size=chunk))
assert output.shape == expected.shape
assert np.isfinite(output).all()
np.testing.assert_allclose(output, expected, atol=1e-5, rtol=1e-5)
def test_invalid_latent_length_rejected(models):
_, _, actual = models
with pytest.raises(ValueError, match='5\\*k-3'):
actual.decode_ntchw(mx.zeros((1, 5, 24, 2, 2)))
def test_cache_tampering_rejected(monkeypatch, tmp_path):
monkeypatch.setattr(Path, 'home', lambda: tmp_path)
cache = tmp_path / '.cache/fastvideo/taehv/taeh3.safetensors'
cache.parent.mkdir(parents=True)
cache.write_bytes(b'corrupt')
with pytest.raises(RuntimeError, match='SHA-256'):
ensure_taeh3_checkpoint()
def test_local_checkpoint_requires_safetensors(tmp_path):
path = tmp_path / 'weights.pth'
path.touch()
with pytest.raises(ValueError, match='safetensors'):
ensure_taeh3_checkpoint(path)
def test_chunk_size_does_not_reset_temporal_state(models):
_, _, actual = models
latent = mx.array(np.random.default_rng(11).standard_normal((1, 12, 24, 2, 3)).astype(np.float32))
expected = np.asarray(actual.decode_ntchw(latent, chunk_size=12))
for size in (1, 3, 5):
np.testing.assert_array_equal(np.asarray(actual.decode_ntchw(latent, chunk_size=size)), expected)
def test_strided_latents_match_contiguous(models):
_, _, actual = models
latent = mx.array(np.random.default_rng(9).standard_normal((1, 7, 24, 2, 6)).astype(np.float32))[:, :, :, :, ::2]
expected = actual.decode_ntchw(mx.contiguous(latent))
np.testing.assert_array_equal(np.asarray(actual.decode_ntchw(latent)), np.asarray(expected))
def test_incompatible_checkpoint_fails_before_decode(tmp_path):
path = tmp_path / 'bad.safetensors'
mx.save_safetensors(str(path), {'decoder.1.weight': mx.zeros((1, 1, 1, 1))})
with pytest.raises(ValueError, match='keys mismatch'):
MLXTAEH3Decoder(path)
def test_failed_download_does_not_leave_cache(monkeypatch, tmp_path):
import io
from fastvideo.mlx_runtime import minimax_h3_taeh3 as module
monkeypatch.setattr(Path, 'home', lambda: tmp_path)
monkeypatch.setattr(module.urllib.request, 'urlopen', lambda *args, **kwargs: io.BytesIO(b'corrupt'))
with pytest.raises(RuntimeError, match='SHA-256'):
ensure_taeh3_checkpoint()
assert list((tmp_path / '.cache/fastvideo/taehv').iterdir()) == []
def test_pipeline_taeh3_skips_full_vae_and_denormalization(monkeypatch):
from fastvideo.mlx_runtime import minimax_h3_taeh3 as module
from fastvideo.mlx_runtime import minimax_h3_video_vae as full_vae
from fastvideo.mlx_runtime.minimax_h3_pipeline import MiniMaxH3MLXPipeline
pipeline = MiniMaxH3MLXPipeline.__new__(MiniMaxH3MLXPipeline)
pipeline.video_decode_backend = 'taeh3'
pipeline.taeh3_checkpoint = None
pipeline.taeh3_chunk_size = 5
pipeline.vae_dtype = 'fp32'
pipeline._dit_in_channels = 24
pipeline._dit_patch_size = (1, 2, 2)
def fail(*args, **kwargs):
raise AssertionError('Full VAE must not load for TAEH3')
def fake_decode(latents, **kwargs):
assert latents.shape == (1, 24, 2, 2, 2)
np.testing.assert_array_equal(latents, np.full_like(latents, 2.0))
return np.full((1, 5, 32, 32, 3), 0.5, dtype=np.float32)
monkeypatch.setattr(full_vae, 'mlx_h3_video_vae_from_dir', fail)
monkeypatch.setattr(module, 'decode_latents_taeh3_mlx', fake_decode)
frames = pipeline.decode_video(np.full((2, 96), 2.0, dtype=np.float32), height=32, width=32, num_frames=5)
assert frames.shape == (5, 32, 32, 3)
assert frames.dtype == np.uint8
assert np.all(frames == 127)