Skip to content

Commit feeecfd

Browse files
committed
[feat] Add MiniMax H3 MLX spatial fast mode
1 parent a4d9a75 commit feeecfd

8 files changed

Lines changed: 244 additions & 17 deletions

File tree

docs/assets/cookbook-recipes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,7 @@
476476
},
477477
"evidence": "Verified",
478478
"expected_artifact": "MP4 with H.264 video and stereo AAC audio at outputs/fasth3_int6.mp4",
479-
"limitations": ["The MLX path supports T2VA and optional temporal --fast mode. FL2VA, Ref2VA, spatial fast mode, two-pass refinement, and VSA are not wired."]
479+
"limitations": ["The MLX path supports T2VA and optional temporal --fast and spatial --fast-spatial modes. FL2VA, Ref2VA, two-pass refinement, and VSA are not wired."]
480480
},
481481
{
482482
"id": "minimax-h3-fl2va",

docs/cookbook/minimax-h3.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ for the download, conversion, and storage requirements.
135135

136136
- The full CUDA H3 examples request four GPUs by default. Their sources do not claim a GPU model or memory minimum.
137137
- The FastH3 CUDA performance profile was measured on four GB200 GPUs. Use its strict profile when exact operation order matters more than the measured performance configuration.
138-
- The MLX source runtime is limited to T2VA. FL2VA, Ref2VA, VSA, spatial fast mode, and two-pass refinement are not wired on MLX.
138+
- The MLX source runtime is limited to T2VA. FL2VA, Ref2VA, VSA, and two-pass refinement are not wired on MLX.
139139
- Gated or missing checkpoints: run `huggingface-cli login` and confirm you accepted the model's license on Hugging Face.
140140

141141
## Evidence status

docs/getting_started/installation/mps.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,22 @@ python examples/inference/basic/mlx_fasth3.py \
192192
--output-path ./outputs/fasth3_int6_fast_720p.mp4
193193
```
194194

195+
Add `--fast-spatial` for spatial fast mode, `--fast`'s spatial twin. It
196+
denoises and decodes on the smallest 32px-aligned canvas covering the
197+
requested size divided by `--fast-spatial-scale` (a 480x832 request runs on a
198+
256x416 canvas), then resamples the decoded frames up to the requested size
199+
in pixel space. It composes with `--fast`:
200+
201+
```bash
202+
python examples/inference/basic/mlx_fasth3.py \
203+
--model-root ./FastH3-Preview-v0.2 \
204+
--mlx-checkpoint ./FastH3-MLX/int6 \
205+
--prompt "(S1) A presenter says <d>[English] Fast H3 is fastest.</d>" \
206+
--height 480 --width 832 --num-frames 124 --seed 2028 \
207+
--fast --fast-spatial \
208+
--output-path ./outputs/fasth3_int6_fast_spatial.mp4
209+
```
210+
195211
VSA is off by default. A dense-only checkpoint (no `--include-vsa`) keeps the
196212
existing fused-SDPA path. After converting with `--include-vsa`, enable the
197213
sparse path explicitly:
@@ -212,8 +228,8 @@ falls back to reference on unsupported shapes. It is not the default.
212228
`--vsa-impl reference` is the same as `auto`.
213229

214230
!!! note "Current MLX scope"
215-
This source runtime supports T2VA, temporal `--fast`, and opt-in VSA.
216-
FL2VA, Ref2VA, spatial fast mode, two-pass refinement, and
231+
This source runtime supports T2VA, temporal `--fast`, spatial
232+
`--fast-spatial`, and opt-in VSA. FL2VA, Ref2VA, two-pass refinement, and
217233
`VideoGenerator` registry dispatch are not wired yet. INT8/INT6/INT4
218234
quantization is **weight-only**; VSA attention Q/K/V stay BF16. Old dense
219235
MLX checkpoints remain valid for dense inference and raise a reconvert

docs/inference/support_matrix.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ optimizations: absence means **untested**, not incompatible.
185185
| MLX FastMetal T2V 1.3B | [`FastVideo/FastMetal-1.3B-QAD`](https://huggingface.co/FastVideo/FastMetal-1.3B-QAD) | 480x832, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 16 GB+ unified memory | Released |
186186
| MLX FastMetal TI2V 5B | [`FastVideo/FastMetal-5B-QAD`](https://huggingface.co/FastVideo/FastMetal-5B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 16 GB+ unified memory | Released |
187187
| MLX FastMetal T2V 14B | [`FastVideo/FastMetal-14B-QAD`](https://huggingface.co/FastVideo/FastMetal-14B-QAD) | 480p / 720p, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 36 GB+ unified memory | Released |
188-
| MLX FastH3 Preview T2VA | [`FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2`](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) + locally converted DiT | 480p / 720p, 124 frames, 4-step DMD2, INT8/INT6/INT4 **weight-only** DiT, native video + audio VAE; optional temporal RIFE fast mode; optional VSA (tile 64/256, exempt/compete) on `--include-vsa` checkpoints | Apple M4 Max, 36 GB unified memory | Source runtime; T2VA only |
188+
| MLX FastH3 Preview T2VA | [`FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2`](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) + locally converted DiT | 480p / 720p, 124 frames, 4-step DMD2, INT8/INT6/INT4 **weight-only** DiT, native video + audio VAE; optional temporal RIFE fast mode; optional spatial fast mode; optional VSA (tile 64/256, exempt/compete) on `--include-vsa` checkpoints | Apple M4 Max, 36 GB unified memory | Source runtime; T2VA only |
189189

190190
Apple Silicon uses the native MLX runtime. FastMetal-QAD is the packaged Wan
191191
release, while FastH3 Preview currently uses a source checkout and local DiT

examples/inference/basic/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ python examples/inference/basic/mlx_fasth3.py \
5151
--output-path ./outputs/fasth3_int6.mp4
5252
```
5353

54-
Pass `--fast` for temporal RIFE fast mode. VSA is opt-in: convert with
55-
`--include-vsa` and pass `--vsa` (see the
54+
Pass `--fast` for temporal RIFE fast mode and `--fast-spatial` for spatial
55+
fast mode (reduced-canvas denoise + pixel-space upsample); the two compose.
56+
VSA is opt-in: convert with `--include-vsa` and pass `--vsa` (see the
5657
[Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/)).
57-
This MLX entrypoint currently supports T2VA only; FL2VA, Ref2VA, spatial fast
58-
mode, and two-pass refinement remain follow-up work. INT6/INT8/INT4 are
58+
This MLX entrypoint currently supports T2VA only; FL2VA, Ref2VA, and
59+
two-pass refinement remain follow-up work. INT6/INT8/INT4 are
5960
weight-only; VSA attention activations stay BF16. Dense-only checkpoints keep
6061
working for dense inference.
6162

examples/inference/basic/mlx_fasth3.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,13 @@
1919
requested frame count. A 1280x720 request runs on H3's 1280x736 grid and is
2020
center-cropped after decode.
2121
22+
``--fast-spatial`` is spatial fast mode, ``--fast``'s spatial twin. It
23+
denoises and decodes on the smallest 32px-aligned canvas covering
24+
height/width divided by ``--fast-spatial-scale``, then resamples the decoded
25+
frames up to the requested size in pixel space. The two modes compose.
26+
2227
This entrypoint currently supports text-to-video-with-audio only. It does not
23-
yet wire FL2VA, Ref2VA, spatial fast mode, or two-pass refinement.
28+
yet wire FL2VA, Ref2VA, or two-pass refinement.
2429
2530
VSA is off by default; existing dense MLX checkpoints remain supported.
2631
H3 uses fused MLX RMSNorm, which can change BF16 rounding compared with the
@@ -71,6 +76,21 @@ def parse_args() -> argparse.Namespace:
7176
help="temporal reduction target for --fast (default: 2)")
7277
parser.add_argument("--fast-sharpen", type=float, default=0.6,
7378
help="unsharp strength after RIFE interpolation (0 disables)")
79+
parser.add_argument(
80+
"--fast-spatial",
81+
action=argparse.BooleanOptionalAction,
82+
default=False,
83+
help="denoise and decode at height/width // fast-spatial-scale on H3's 32px grid, "
84+
"then resample the decoded frames up to the requested size; composes with --fast",
85+
)
86+
parser.add_argument("--fast-spatial-scale", type=int, default=2,
87+
help="spatial reduction factor for --fast-spatial (default: 2)")
88+
parser.add_argument("--fast-spatial-upsample-mode",
89+
choices=("lanczos", "cubic", "bilinear", "nearest"),
90+
default="lanczos",
91+
help="pixel interpolation kernel for the post-decode upsample")
92+
parser.add_argument("--fast-spatial-sharpen", type=float, default=0.4,
93+
help="unsharp strength after the upsample (0 disables)")
7494
parser.add_argument("--rife-weights-dir", type=Path, default=None,
7595
help="optional local mlx-community/RIFE-4.25 snapshot")
7696
parser.add_argument("--vae-dtype", choices=("fp32", "fp16", "bf16"), default="fp32")
@@ -160,6 +180,10 @@ def main() -> None:
160180
fast_factor=args.fast_factor,
161181
fast_sharpen=args.fast_sharpen,
162182
rife_weights_dir=args.rife_weights_dir,
183+
fast_spatial=args.fast_spatial,
184+
fast_spatial_scale=args.fast_spatial_scale,
185+
fast_spatial_upsample_mode=args.fast_spatial_upsample_mode,
186+
fast_spatial_sharpen=args.fast_spatial_sharpen,
163187
vsa=args.vsa,
164188
vsa_sparsity=args.vsa_sparsity,
165189
vsa_tile_size=args.vsa_tile_size,

fastvideo/mlx_runtime/minimax_h3_pipeline.py

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
import numpy as np
3434

3535
from fastvideo.logger import init_logger
36+
from fastvideo.mlx_runtime.frame_upsample import (
37+
DEFAULT_PIXEL_UPSAMPLE_MODE,
38+
PIXEL_UPSAMPLE_MODES,
39+
upsample_frames,
40+
)
3641
from fastvideo.mlx_runtime.minimax_h3 import (
3742
H3_MANIFEST_FILENAME,
3843
MINIMAX_H3_AUDIO_SHIFT,
@@ -99,6 +104,72 @@ def plan_fast_temporal(target_frames: int, factor: int = 2) -> FastTemporalPlan:
99104
)
100105

101106

107+
# Resampling from a smaller decode softens output the same way on every
108+
# runtime; 0.4 matches the tuned Wan default without the halos that show
109+
# up by ~0.8.
110+
DEFAULT_FAST_SPATIAL_SHARPEN = 0.4
111+
112+
113+
@dataclass(frozen=True)
114+
class FastSpatialPlan:
115+
"""Reduced-canvas geometry for spatial fast mode (RIFE's spatial twin)."""
116+
117+
target_height: int
118+
target_width: int
119+
stage1_height: int
120+
stage1_width: int
121+
canvas_height: int
122+
canvas_width: int
123+
scale: int
124+
upsample_mode: str
125+
sharpen: float
126+
127+
128+
def plan_fast_spatial(
129+
height: int,
130+
width: int,
131+
*,
132+
scale: int = 2,
133+
upsample_mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
134+
sharpen: float = DEFAULT_FAST_SPATIAL_SHARPEN,
135+
) -> FastSpatialPlan:
136+
"""Choose the smallest H3-valid canvas that covers ``target / scale``.
137+
138+
H3 geometry rounds *up* to the 32px model grid and center-crops after
139+
decode — the same convention plain 720p generation uses via
140+
``_model_canvas_size`` — so no size the full-resolution path accepts is
141+
rejected here. The return trip to the target size runs in pixel space
142+
after the VAE decode, never on latents; see
143+
:mod:`fastvideo.mlx_runtime.frame_upsample` for why.
144+
"""
145+
if scale < 2:
146+
raise ValueError(f"fast-spatial scale must be at least 2, got {scale}.")
147+
if upsample_mode not in PIXEL_UPSAMPLE_MODES:
148+
raise ValueError(f"Unsupported upsample mode: {upsample_mode!r} "
149+
f"(expected one of {', '.join(PIXEL_UPSAMPLE_MODES)})")
150+
if sharpen < 0:
151+
raise ValueError(f"fast_spatial_sharpen must be non-negative, got {sharpen}.")
152+
target_canvas_height, target_canvas_width = _model_canvas_size(height, width)
153+
stage1_height = math.ceil(height / scale)
154+
stage1_width = math.ceil(width / scale)
155+
canvas_height, canvas_width = _model_canvas_size(stage1_height, stage1_width)
156+
if canvas_height * canvas_width >= target_canvas_height * target_canvas_width:
157+
raise ValueError(
158+
f"fast-spatial scale {scale} does not reduce the H3 canvas for {height}x{width} "
159+
f"(stage-1 canvas {canvas_width}x{canvas_height} vs {target_canvas_width}x{target_canvas_height}).")
160+
return FastSpatialPlan(
161+
target_height=height,
162+
target_width=width,
163+
stage1_height=stage1_height,
164+
stage1_width=stage1_width,
165+
canvas_height=canvas_height,
166+
canvas_width=canvas_width,
167+
scale=scale,
168+
upsample_mode=upsample_mode,
169+
sharpen=sharpen,
170+
)
171+
172+
102173
def _model_canvas_size(height: int, width: int) -> tuple[int, int]:
103174
"""Round an exact output size up to H3's 32-pixel model grid."""
104175
if height <= 0 or width <= 0:
@@ -202,10 +273,16 @@ def _validate_checkpoint_step_ladder(checkpoint_dir: str | Path, num_steps: int)
202273
f"{num_steps}. Use the step count used during conversion (normally 4), or re-export the checkpoint.")
203274

204275

205-
def _preflight_media_dependencies(*, fast: bool, fast_sharpen: float, rife_weights_dir: str | Path | None) -> None:
276+
def _preflight_media_dependencies(*,
277+
fast: bool,
278+
fast_sharpen: float,
279+
rife_weights_dir: str | Path | None,
280+
fast_spatial: bool = False) -> None:
206281
"""Fail before conditioning when required output dependencies are unavailable."""
207282
if shutil.which("ffmpeg") is None:
208283
raise RuntimeError("ffmpeg is required for MP4 muxing; install it before generation.")
284+
if fast_spatial and importlib.util.find_spec("cv2") is None:
285+
raise RuntimeError("OpenCV is required for --fast-spatial resampling.")
209286
if not fast:
210287
return
211288
if fast_sharpen > 0 and importlib.util.find_spec("cv2") is None:
@@ -629,6 +706,10 @@ def generate(
629706
fast_factor: int = 2,
630707
fast_sharpen: float = 0.6,
631708
rife_weights_dir: str | Path | None = None,
709+
fast_spatial: bool = False,
710+
fast_spatial_scale: int = 2,
711+
fast_spatial_upsample_mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
712+
fast_spatial_sharpen: float = DEFAULT_FAST_SPATIAL_SHARPEN,
632713
vsa: bool = False,
633714
vsa_sparsity: float = 0.9,
634715
vsa_tile_size: int = 64,
@@ -654,12 +735,23 @@ def generate(
654735
) if vsa else MiniMaxH3VSAConfig()
655736
if vsa_config.enabled and not mlx_h3_checkpoint_vsa_capable(self.dit_checkpoint):
656737
raise dense_only_vsa_error(self.dit_checkpoint)
738+
spatial_plan = plan_fast_spatial(
739+
height,
740+
width,
741+
scale=fast_spatial_scale,
742+
upsample_mode=fast_spatial_upsample_mode,
743+
sharpen=fast_spatial_sharpen,
744+
) if fast_spatial else None
657745
_preflight_media_dependencies(
658746
fast=fast,
659747
fast_sharpen=fast_sharpen,
660748
rife_weights_dir=rife_weights_dir,
749+
fast_spatial=fast_spatial,
661750
)
662-
canvas_height, canvas_width = _model_canvas_size(height, width)
751+
if spatial_plan is not None:
752+
canvas_height, canvas_width = spatial_plan.canvas_height, spatial_plan.canvas_width
753+
else:
754+
canvas_height, canvas_width = _model_canvas_size(height, width)
663755
target_geometry = self.resolve_geometry(canvas_height, canvas_width, num_frames)
664756
fast_plan = plan_fast_temporal(target_geometry["num_frames"], fast_factor) if fast else None
665757
video_num_frames = fast_plan.source_frames if fast_plan is not None else target_geometry["num_frames"]
@@ -671,7 +763,7 @@ def generate(
671763
enforce_duration=fast_plan is None,
672764
)
673765
logger.info(
674-
"Geometry: output=%dx%dx%d model=%dx%dx%d audio_frames=%d fast=%s",
766+
"Geometry: output=%dx%dx%d model=%dx%dx%d audio_frames=%d fast=%s fast_spatial=%s",
675767
width,
676768
height,
677769
target_geometry["num_frames"],
@@ -680,6 +772,7 @@ def generate(
680772
video_geometry["num_frames"],
681773
target_geometry["num_frames"],
682774
fast_plan,
775+
spatial_plan,
683776
)
684777

685778
_reset_peak_memory()
@@ -718,7 +811,10 @@ def generate(
718811
num_frames=video_geometry["num_frames"],
719812
tiled=tiled_video_decode,
720813
)
721-
frames = _center_crop_frames(frames, height, width)
814+
if spatial_plan is not None:
815+
frames = _center_crop_frames(frames, spatial_plan.stage1_height, spatial_plan.stage1_width)
816+
else:
817+
frames = _center_crop_frames(frames, height, width)
722818
timings["video_decode_s"] = time.perf_counter() - started
723819
peaks["video_decode_gib"] = _peak_memory_gib()
724820
_cleanup_mlx()
@@ -735,7 +831,8 @@ def generate(
735831
target_geometry["num_frames"],
736832
model=model,
737833
)
738-
interpolated = _sharpen_frames(interpolated, fast_sharpen)
834+
if spatial_plan is None:
835+
interpolated = _sharpen_frames(interpolated, fast_sharpen)
739836
frames = np.stack(interpolated)
740837
if frames.shape[0] != target_geometry["num_frames"]:
741838
raise RuntimeError(
@@ -748,6 +845,22 @@ def generate(
748845
del model
749846
_cleanup_mlx()
750847

848+
if spatial_plan is not None:
849+
started = time.perf_counter()
850+
# One sharpen pass, at full resolution: RIFE and the resample soften
851+
# for the same reason, so the stronger requested amount is applied
852+
# once instead of stacking two unsharp masks.
853+
sharpen = spatial_plan.sharpen if fast_plan is None else max(spatial_plan.sharpen, fast_sharpen)
854+
frames = np.stack(
855+
upsample_frames(
856+
frames,
857+
width=spatial_plan.target_width,
858+
height=spatial_plan.target_height,
859+
mode=spatial_plan.upsample_mode,
860+
sharpen=sharpen,
861+
))
862+
timings["spatial_upsample_s"] = time.perf_counter() - started
863+
751864
_reset_peak_memory()
752865
started = time.perf_counter()
753866
waveform = self.decode_audio(audio_rows, num_frames=target_geometry["num_frames"])
@@ -759,8 +872,8 @@ def generate(
759872
video_path = self.mux(frames, waveform, output_path)
760873
timings["mux_s"] = time.perf_counter() - started
761874
timings["generate_s"] = sum(
762-
timings.get(key, 0.0)
763-
for key in ("condition_s", "denoise_s", "video_decode_s", "rife_s", "audio_decode_s", "mux_s"))
875+
timings.get(key, 0.0) for key in ("condition_s", "denoise_s", "video_decode_s", "rife_s",
876+
"spatial_upsample_s", "audio_decode_s", "mux_s"))
764877

765878
result = GenerationResult(
766879
video_path=str(video_path),

0 commit comments

Comments
 (0)